diff --git a/.changes/unreleased/added-20260403-122500.yaml b/.changes/unreleased/added-20260403-122500.yaml new file mode 100644 index 000000000..a196168f0 --- /dev/null +++ b/.changes/unreleased/added-20260403-122500.yaml @@ -0,0 +1,5 @@ +kind: added +body: Add Azure DevOps-backed Dataverse Git integration resources for environment bindings and solution branch bindings, including environment-scope solution enablement behavior and known authentication limitations. +time: 2026-04-03T12:25:00Z +custom: + Issue: "1104" diff --git a/docs/resources/environment_git_integration.md b/docs/resources/environment_git_integration.md new file mode 100644 index 000000000..c73106291 --- /dev/null +++ b/docs/resources/environment_git_integration.md @@ -0,0 +1,101 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "powerplatform_environment_git_integration Resource - Power Platform" +subcategory: "" +description: |- + Manages the environment-level Dataverse Git repository binding. This maps to the documented sourcecontrolconfiguration Dataverse table and stores the repository connection metadata for an environment. + Known limitation: the underlying Power Platform Git integration bootstrap currently requires delegated user principal authentication with Azure DevOps access. Service principal, app-only, and OIDC pipeline identities are not currently supported by the backing Dataverse Git integration flow. +--- + +# powerplatform_environment_git_integration (Resource) + +Manages the environment-level Dataverse Git repository binding. This maps to the documented `sourcecontrolconfiguration` Dataverse table and stores the repository connection metadata for an environment. + +Known limitation: the underlying Power Platform Git integration bootstrap currently requires delegated user principal authentication with Azure DevOps access. Service principal, app-only, and OIDC pipeline identities are not currently supported by the backing Dataverse Git integration flow. + +## Example Usage + +```terraform +terraform { + required_providers { + powerplatform = { + source = "microsoft/power-platform" + } + } +} + +provider "powerplatform" { + use_cli = true +} + +# Known limitation: Dataverse Git integration currently works only with delegated +# user principal authentication that also has Azure DevOps repository access. +# Service principal, app-only, and OIDC pipeline identities are not supported. + +# Use `scope = "Environment"` to mirror the maker UI environment-level binding. +# In this mode the provider manages the root Dataverse binding and proactively +# enables eligible visible unmanaged solutions in the environment. Built-in +# platform solutions are excluded automatically. +resource "powerplatform_environment" "example" { + display_name = var.environment_display_name + description = "Example environment for validating Dataverse Git integration." + location = var.location + azure_region = var.azure_region + environment_type = "Sandbox" + dataverse = { + language_code = "1033" + currency_code = "USD" + security_group_id = var.security_group_id + } +} + +resource "powerplatform_environment_git_integration" "example" { + environment_id = powerplatform_environment.example.id + git_provider = var.git_provider + scope = var.scope + organization_name = var.organization_name + project_name = var.project_name + repository_name = var.repository_name +} +``` + + +## Schema + +### Required + +- `environment_id` (String) Environment ID of the Dataverse environment where the Git repository binding will be created. +- `git_provider` (String) Git provider for the repository binding. Supported value is `AzureDevOps`. +- `organization_name` (String) Organization or owner name for the configured Git provider. +- `project_name` (String) Project name for the Azure DevOps repository binding. +- `repository_name` (String) Repository name that the environment will bind to. +- `scope` (String) Source control integration scope for the environment. Use `Solution` for solution-level branch bindings and `Environment` for an environment-level binding. In `Environment` scope, the provider manages the root branch binding and proactively enables eligible visible unmanaged solutions in the environment while excluding platform-owned default solutions. + +### Optional + +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) + +### Read-Only + +- `id` (String) Unique identifier of the Dataverse source control configuration. + + +### 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 +# Environment Git integration resources can be imported using the environment id (replace with a real environment id) +terraform import powerplatform_environment_git_integration.example 00000000-0000-0000-0000-000000000000 +``` diff --git a/docs/resources/solution_git_branch.md b/docs/resources/solution_git_branch.md new file mode 100644 index 000000000..b6ba0670e --- /dev/null +++ b/docs/resources/solution_git_branch.md @@ -0,0 +1,152 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "powerplatform_solution_git_branch Resource - Power Platform" +subcategory: "" +description: |- + Manages a solution-level Dataverse Git branch binding. This maps to the documented sourcecontrolbranchconfiguration Dataverse table and links a solution partition to a branch and folder beneath an environment Git integration. + Known limitation: the underlying Power Platform Git integration bootstrap currently requires delegated user principal authentication with Azure DevOps access. Service principal, app-only, and OIDC pipeline identities are not currently supported by the backing Dataverse Git integration flow. +--- + +# powerplatform_solution_git_branch (Resource) + +Manages a solution-level Dataverse Git branch binding. This maps to the documented `sourcecontrolbranchconfiguration` Dataverse table and links a solution partition to a branch and folder beneath an environment Git integration. + +Known limitation: the underlying Power Platform Git integration bootstrap currently requires delegated user principal authentication with Azure DevOps access. Service principal, app-only, and OIDC pipeline identities are not currently supported by the backing Dataverse Git integration flow. + +## Example Usage + +```terraform +terraform { + required_providers { + local = { + source = "hashicorp/local" + version = "2.6.2" + } + powerplatform = { + source = "microsoft/power-platform" + } + } +} + +provider "local" {} + +provider "powerplatform" { + use_cli = true +} + +# Known limitation: Dataverse Git integration currently works only with delegated +# user principal authentication that also has Azure DevOps repository access. +# Service principal, app-only, and OIDC pipeline identities are not supported. + +resource "local_file" "solution_settings_file" { + filename = "${path.module}/solution_settings.json" + content = < +## Schema + +### Required + +- `branch_name` (String) Branch name to bind the solution partition to. +- `environment_id` (String) Environment ID of the Dataverse environment where the branch binding exists. +- `git_integration_id` (String) ID of the parent `powerplatform_environment_git_integration` resource. +- `root_folder_path` (String) Repository folder path that stores the solution's files. +- `solution_id` (String) ID of the existing `powerplatform_solution` resource to bind to the Git branch. This must use the provider solution ID format for the same environment. + +### Optional + +- `timeouts` (Attributes) (see [below for nested schema](#nestedatt--timeouts)) +- `upstream_branch_name` (String) Upstream branch name. When omitted, the provider will use the same value as `branch_name`. + +### Read-Only + +- `id` (String) Unique identifier of the Dataverse source control branch configuration. + + +### 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 +# Solution Git branch resources can be imported using environment_id/git_integration_id/solution_id +# The final segment can be either the raw Dataverse solution id or the provider-formatted powerplatform_solution.id +terraform import powerplatform_solution_git_branch.example 00000000-0000-0000-0000-000000000000/11111111-1111-1111-1111-111111111111/00000000-0000-0000-0000-000000000000_22222222-2222-2222-2222-222222222222 +``` diff --git a/examples/resources/powerplatform_environment_git_integration/import.sh b/examples/resources/powerplatform_environment_git_integration/import.sh new file mode 100644 index 000000000..a3d6b2114 --- /dev/null +++ b/examples/resources/powerplatform_environment_git_integration/import.sh @@ -0,0 +1,2 @@ +# Environment Git integration resources can be imported using the environment id (replace with a real environment id) +terraform import powerplatform_environment_git_integration.example 00000000-0000-0000-0000-000000000000 diff --git a/examples/resources/powerplatform_environment_git_integration/outputs.tf b/examples/resources/powerplatform_environment_git_integration/outputs.tf new file mode 100644 index 000000000..616a40477 --- /dev/null +++ b/examples/resources/powerplatform_environment_git_integration/outputs.tf @@ -0,0 +1,9 @@ +output "environment_id" { + description = "Unique identifier of the example environment." + value = powerplatform_environment.example.id +} + +output "git_integration_id" { + description = "Unique identifier of the environment Git integration binding." + value = powerplatform_environment_git_integration.example.id +} diff --git a/examples/resources/powerplatform_environment_git_integration/resource.tf b/examples/resources/powerplatform_environment_git_integration/resource.tf new file mode 100644 index 000000000..43bbee413 --- /dev/null +++ b/examples/resources/powerplatform_environment_git_integration/resource.tf @@ -0,0 +1,41 @@ +terraform { + required_providers { + powerplatform = { + source = "microsoft/power-platform" + } + } +} + +provider "powerplatform" { + use_cli = true +} + +# Known limitation: Dataverse Git integration currently works only with delegated +# user principal authentication that also has Azure DevOps repository access. +# Service principal, app-only, and OIDC pipeline identities are not supported. + +# Use `scope = "Environment"` to mirror the maker UI environment-level binding. +# In this mode the provider manages the root Dataverse binding and proactively +# enables eligible visible unmanaged solutions in the environment. Built-in +# platform solutions are excluded automatically. +resource "powerplatform_environment" "example" { + display_name = var.environment_display_name + description = "Example environment for validating Dataverse Git integration." + location = var.location + azure_region = var.azure_region + environment_type = "Sandbox" + dataverse = { + language_code = "1033" + currency_code = "USD" + security_group_id = var.security_group_id + } +} + +resource "powerplatform_environment_git_integration" "example" { + environment_id = powerplatform_environment.example.id + git_provider = var.git_provider + scope = var.scope + organization_name = var.organization_name + project_name = var.project_name + repository_name = var.repository_name +} diff --git a/examples/resources/powerplatform_environment_git_integration/variables.tf b/examples/resources/powerplatform_environment_git_integration/variables.tf new file mode 100644 index 000000000..bb4341ed4 --- /dev/null +++ b/examples/resources/powerplatform_environment_git_integration/variables.tf @@ -0,0 +1,53 @@ +variable "environment_display_name" { + default = "example-git-integration-environment" + description = "Display name of the example environment." + type = string +} + +variable "location" { + default = "europe" + description = "Power Platform geography for the example environment." + type = string +} + +variable "azure_region" { + default = "northeurope" + description = "Azure region for the Dataverse-backed example environment." + type = string +} + +variable "security_group_id" { + default = "00000000-0000-0000-0000-000000000000" + description = "Security group ID for Dataverse provisioning. Use the zero GUID to disable." + type = string +} + +variable "git_provider" { + default = "AzureDevOps" + description = "Git provider to bind. Supported value is AzureDevOps." + type = string +} + +variable "scope" { + default = "Environment" + description = "Source control integration scope. Use Environment for environment-scoped bindings or Solution when pairing with powerplatform_solution_git_branch." + type = string +} + +variable "organization_name" { + default = "example-org" + description = "Git organization or owner name." + type = string +} + +variable "project_name" { + default = "example-project" + description = "Git project name used for Azure DevOps bindings." + type = string +} + +variable "repository_name" { + default = "example-repo" + description = "Git repository name to bind to the environment." + type = string +} diff --git a/examples/resources/powerplatform_solution_git_branch/import.sh b/examples/resources/powerplatform_solution_git_branch/import.sh new file mode 100644 index 000000000..a80d80698 --- /dev/null +++ b/examples/resources/powerplatform_solution_git_branch/import.sh @@ -0,0 +1,3 @@ +# Solution Git branch resources can be imported using environment_id/git_integration_id/solution_id +# The final segment can be either the raw Dataverse solution id or the provider-formatted powerplatform_solution.id +terraform import powerplatform_solution_git_branch.example 00000000-0000-0000-0000-000000000000/11111111-1111-1111-1111-111111111111/00000000-0000-0000-0000-000000000000_22222222-2222-2222-2222-222222222222 diff --git a/examples/resources/powerplatform_solution_git_branch/outputs.tf b/examples/resources/powerplatform_solution_git_branch/outputs.tf new file mode 100644 index 000000000..0bca109c0 --- /dev/null +++ b/examples/resources/powerplatform_solution_git_branch/outputs.tf @@ -0,0 +1,19 @@ +output "environment_id" { + description = "Unique identifier of the example environment." + value = powerplatform_environment.example.id +} + +output "solution_id" { + description = "Provider-formatted ID of the example unmanaged solution." + value = powerplatform_solution.example.id +} + +output "git_integration_id" { + description = "Unique identifier of the environment Git integration binding, if enabled." + value = try(powerplatform_environment_git_integration.example[0].id, null) +} + +output "solution_git_branch_id" { + description = "Unique identifier of the solution Git branch binding, if enabled." + value = try(powerplatform_solution_git_branch.example[0].id, null) +} diff --git a/examples/resources/powerplatform_solution_git_branch/resource.tf b/examples/resources/powerplatform_solution_git_branch/resource.tf new file mode 100644 index 000000000..5eb19d2e3 --- /dev/null +++ b/examples/resources/powerplatform_solution_git_branch/resource.tf @@ -0,0 +1,91 @@ +terraform { + required_providers { + local = { + source = "hashicorp/local" + version = "2.6.2" + } + powerplatform = { + source = "microsoft/power-platform" + } + } +} + +provider "local" {} + +provider "powerplatform" { + use_cli = true +} + +# Known limitation: Dataverse Git integration currently works only with delegated +# user principal authentication that also has Azure DevOps repository access. +# Service principal, app-only, and OIDC pipeline identities are not supported. + +resource "local_file" "solution_settings_file" { + filename = "${path.module}/solution_settings.json" + content = <= stabilizedReadCountRequired { + return configuration, nil + } + } + + if err := c.Api.SleepWithContext(ctx, api.DefaultRetryAfter()); err != nil { + return nil, err + } + } +} + +func (c *client) EnsureRootBranchConfiguration(ctx context.Context, environmentID, configurationID, organizationName, projectName, repositoryName string) error { + defaultBranch, err := c.GetGitRepositoryDefaultBranch(ctx, environmentID, organizationName, projectName, repositoryName) + if err != nil { + return err + } + + existingBranch, err := c.lookupAnySolutionGitBranchByPartition(ctx, environmentID, configurationID, rootPartitionID) + if err == nil { + if existingBranch.StatusCode == sourceControlBranchConfigurationStatusActive && + strings.EqualFold(existingBranch.BranchName, defaultBranch) && + strings.EqualFold(existingBranch.UpstreamBranchName, defaultBranch) && + strings.EqualFold(existingBranch.RootFolderPath, rootFolderPath) { + _, err = c.waitForBranchConfigurationState(ctx, environmentID, configurationID, rootPartitionID, defaultBranch, defaultBranch, rootFolderPath) + return err + } + + if existingBranch.StatusCode == sourceControlBranchConfigurationStatusActive { + _, err = c.UpdateSolutionGitBranch(ctx, environmentID, existingBranch.ID, configurationID, rootPartitionID, updateSourceControlBranchConfigurationDto{ + BranchName: defaultBranch, + UpstreamBranchName: defaultBranch, + RootFolderPath: rootFolderPath, + }) + return err + } + } + if err != nil && !errors.Is(err, customerrors.ErrObjectNotFound) { + return err + } + + _, err = c.CreateBranchConfiguration(ctx, environmentID, createSourceControlBranchConfigurationDto{ + ID: "", + PartitionID: rootPartitionID, + BranchName: defaultBranch, + UpstreamBranchName: defaultBranch, + RootFolderPath: rootFolderPath, + SourceControlConfigurationBindID: fmt.Sprintf("/sourcecontrolconfigurations(%s)", configurationID), + }, configurationID) + return err +} + +func (c *client) UpdateEnvironmentGitIntegration(ctx context.Context, environmentID, configurationID string, dto updateSourceControlConfigurationDto) (*sourceControlConfigurationDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.0/sourcecontrolconfigurations(%s)", configurationID), nil) + resp, err := c.Api.Execute(ctx, nil, http.MethodPatch, apiURL, nil, dto, []int{http.StatusNoContent, http.StatusForbidden, http.StatusNotFound}, nil) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if resp.HttpResponse.StatusCode == http.StatusNotFound { + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "source control configuration not found") + } + + return c.GetEnvironmentGitIntegration(ctx, environmentID, configurationID) +} + +func (c *client) DeleteEnvironmentGitIntegration(ctx context.Context, environmentID, configurationID string) error { + _, err := c.lookupAnySolutionGitBranchByPartition(ctx, environmentID, configurationID, rootPartitionID) + if err != nil && !errors.Is(err, customerrors.ErrObjectNotFound) { + return err + } + if err == nil { + if err := c.DeleteSolutionGitBranch(ctx, environmentID, configurationID, rootPartitionID); err != nil { + return err + } + } + + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.0/sourcecontrolconfigurations(%s)", configurationID), nil) + resp, err := c.Api.Execute(ctx, nil, http.MethodDelete, apiURL, nil, nil, []int{http.StatusNoContent, http.StatusNotFound, http.StatusForbidden}, nil) + if err != nil { + // Dataverse implicitly removes the parent config after the last solution binding disconnects, + // but a direct DELETE can still return the legacy "can't be deleted" error afterwards. + if strings.Contains(err.Error(), "Existing source control configurations can't be deleted.") { + _, readErr := c.GetEnvironmentGitIntegration(ctx, environmentID, configurationID) + if errors.Is(readErr, customerrors.ErrObjectNotFound) { + return nil + } + } + return err + } + return c.Api.HandleForbiddenResponse(resp) +} + +func (c *client) ListEnvironmentScopeSolutions(ctx context.Context, environmentID string) ([]unmanagedSolutionDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + values := url.Values{} + values.Add("$filter", "(ismanaged eq false and isvisible eq true)") + values.Add("$select", "solutionid,uniquename,friendlyname,ismanaged,isvisible,enabledforsourcecontrolintegration,version") + + var solutions unmanagedSolutionArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.2/solutions", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &solutions) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + filtered := make([]unmanagedSolutionDto, 0, len(solutions.Value)) + for _, solutionRow := range solutions.Value { + if isEnvironmentScopeCandidateSolution(solutionRow) { + filtered = append(filtered, solutionRow) + } + } + + return filtered, nil +} + +func (c *client) GetSolutionGitBranch(ctx context.Context, environmentID, branchID string) (*sourceControlBranchConfigurationDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.0/sourcecontrolbranchconfigurations(%s)", branchID), nil) + + var dto sourceControlBranchConfigurationDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, apiURL, nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &dto) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if resp.HttpResponse.StatusCode == http.StatusNotFound { + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "source control branch configuration not found") + } + + return &dto, nil +} + +func (c *client) CreateSolutionGitBranch(ctx context.Context, environmentID, solutionUniqueName string, dto createSourceControlBranchConfigurationDto) (*sourceControlBranchConfigurationDto, error) { + configurationID := strings.TrimPrefix(strings.TrimSuffix(dto.SourceControlConfigurationBindID, ")"), "/sourcecontrolconfigurations(") + if _, err := c.CreateBranchConfiguration(ctx, environmentID, dto, configurationID); err != nil { + return nil, err + } + + configuration, err := c.GetEnvironmentGitIntegration(ctx, environmentID, configurationID) + if err != nil { + return nil, err + } + + if err := c.EnsureRootBranchConfiguration(ctx, environmentID, configurationID, configuration.OrganizationName, configuration.ProjectName, configuration.RepositoryName); err != nil { + return nil, err + } + + return c.waitForBranchConfigurationState(ctx, environmentID, configurationID, dto.PartitionID, dto.BranchName, dto.UpstreamBranchName, dto.RootFolderPath, solutionUniqueName) +} + +func (c *client) CreateBranchConfiguration(ctx context.Context, environmentID string, dto createSourceControlBranchConfigurationDto, configurationID string) (*sourceControlBranchConfigurationDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/sourcecontrolbranchconfigurations", nil) + resp, err := c.Api.Execute(ctx, nil, http.MethodPost, apiURL, nil, dto, []int{http.StatusNoContent, http.StatusCreated, http.StatusForbidden}, nil) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + + return c.waitForBranchConfigurationState(ctx, environmentID, configurationID, dto.PartitionID, dto.BranchName, dto.UpstreamBranchName, dto.RootFolderPath) +} + +func (c *client) PreValidateGitComponents(ctx context.Context, environmentID, solutionUniqueName string) (bool, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return false, err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/PreValidateGitComponents", nil) + var response preValidateGitComponentsResponseDto + resp, err := c.Api.Execute(ctx, nil, http.MethodPost, apiURL, nil, preValidateGitComponentsRequestDto{ + SolutionUniqueName: solutionUniqueName, + }, []int{http.StatusOK, http.StatusBadRequest, http.StatusForbidden, http.StatusNotFound}, &response) + if err != nil { + return false, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return false, err + } + if resp.HttpResponse.StatusCode == http.StatusNotFound || resp.HttpResponse.StatusCode == http.StatusBadRequest { + return false, nil + } + + return strings.TrimSpace(response.ValidationMessages) == "", nil +} + +func (c *client) waitForBranchConfigurationState(ctx context.Context, environmentID, configurationID, partitionID, branchName, upstreamBranchName, rootFolder string, solutionUniqueName ...string) (*sourceControlBranchConfigurationDto, error) { + stableReads := 0 + var uniqueName string + if len(solutionUniqueName) > 0 { + uniqueName = solutionUniqueName[0] + } + + for { + branch, err := c.FindSolutionGitBranchByPartition(ctx, environmentID, configurationID, partitionID) + if err == nil { + if strings.EqualFold(branch.PartitionID, partitionID) && + strings.EqualFold(branch.BranchName, branchName) && + strings.EqualFold(branch.UpstreamBranchName, upstreamBranchName) && + strings.EqualFold(branch.RootFolderPath, rootFolder) && + strings.EqualFold(branch.SourceControlConfiguration, configurationID) && + branch.StatusCode == sourceControlBranchConfigurationStatusActive && + branch.BranchSyncedCommitID != "" && + branch.UpstreamBranchSyncedCommit != "" { + if uniqueName != "" { + preValidated, err := c.PreValidateGitComponents(ctx, environmentID, uniqueName) + if err != nil { + return nil, err + } + if !preValidated { + stableReads = 0 + goto sleep + } + } + + stableReads++ + if stableReads >= stabilizedReadCountRequired { + return branch, nil + } + } else { + stableReads = 0 + } + } else if !errors.Is(err, customerrors.ErrObjectNotFound) { + return nil, err + } + + sleep: + if err := c.Api.SleepWithContext(ctx, api.DefaultRetryAfter()); err != nil { + return nil, err + } + } +} + +func (c *client) UpdateSolutionGitBranch(ctx context.Context, environmentID, branchID, configurationID, partitionID string, dto updateSourceControlBranchConfigurationDto) (*sourceControlBranchConfigurationDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, buildSourceControlBranchConfigurationCompositeKeyPath(branchID, partitionID), nil) + headers := http.Header{} + headers.Set("If-Match", "*") + resp, err := c.Api.Execute(ctx, nil, http.MethodPatch, apiURL, headers, dto, []int{http.StatusNoContent, http.StatusForbidden, http.StatusNotFound}, nil) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if resp.HttpResponse.StatusCode == http.StatusNotFound { + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "source control branch configuration not found") + } + + return c.FindSolutionGitBranchByPartition(ctx, environmentID, configurationID, partitionID) +} + +func (c *client) DeleteSolutionGitBranch(ctx context.Context, environmentID, configurationID, partitionID string) error { + existingBranch, err := c.lookupAnySolutionGitBranchByPartition(ctx, environmentID, configurationID, partitionID) + if err != nil { + if errors.Is(err, customerrors.ErrObjectNotFound) { + return nil + } + return err + } + + if existingBranch.StatusCode == sourceControlBranchConfigurationStatusInactive { + return c.waitForSolutionGitBranchRemoval(ctx, environmentID, existingBranch.ID, configurationID, partitionID) + } + + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, buildSourceControlBranchConfigurationCompositeKeyPath(existingBranch.ID, partitionID), nil) + headers := http.Header{} + headers.Set("If-Match", "*") + resp, err := c.Api.Execute(ctx, nil, http.MethodPatch, apiURL, headers, disableSourceControlBranchConfigurationDto{ + StatusCode: sourceControlBranchConfigurationStatusInactive, + }, []int{http.StatusNoContent, http.StatusNotFound, http.StatusForbidden}, nil) + if err != nil { + return err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return err + } + if resp.HttpResponse.StatusCode == http.StatusNotFound { + return nil + } + + return c.waitForSolutionGitBranchRemoval(ctx, environmentID, existingBranch.ID, configurationID, partitionID) +} + +func (c *client) waitForSolutionGitBranchRemoval(ctx context.Context, environmentID, branchID, configurationID, partitionID string) error { + for { + branches, err := c.ListSourceControlBranchConfigurationsByPartition(ctx, environmentID, partitionID) + if err != nil { + return err + } + + found := false + for _, branch := range branches { + if strings.EqualFold(branch.ID, branchID) || (strings.EqualFold(branch.SourceControlConfiguration, configurationID) && strings.EqualFold(branch.PartitionID, partitionID)) { + found = true + break + } + } + if !found { + return nil + } + + if err := c.Api.SleepWithContext(ctx, api.DefaultRetryAfter()); err != nil { + return err + } + } +} + +func (c *client) FindSolutionGitBranchByPartition(ctx context.Context, environmentID, configurationID, partitionID string) (*sourceControlBranchConfigurationDto, error) { + return c.lookupActiveSolutionGitBranchByPartition(ctx, environmentID, configurationID, partitionID) +} + +func (c *client) lookupActiveSolutionGitBranchByPartition(ctx context.Context, environmentID, configurationID, partitionID string) (*sourceControlBranchConfigurationDto, error) { + branches, err := c.ListSourceControlBranchConfigurationsByPartition(ctx, environmentID, partitionID) + if err != nil { + return nil, err + } + + for _, branch := range branches { + if strings.EqualFold(branch.SourceControlConfiguration, configurationID) && branch.StatusCode == sourceControlBranchConfigurationStatusActive { + return &branch, nil + } + } + + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "source control branch configuration not found") +} + +func (c *client) lookupAnySolutionGitBranchByPartition(ctx context.Context, environmentID, configurationID, partitionID string) (*sourceControlBranchConfigurationDto, error) { + branches, err := c.ListSourceControlBranchConfigurationsByPartition(ctx, environmentID, partitionID) + if err != nil { + return nil, err + } + + for _, branch := range branches { + if strings.EqualFold(branch.SourceControlConfiguration, configurationID) { + return &branch, nil + } + } + + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "source control branch configuration not found") +} + +func (c *client) ListSourceControlBranchConfigurations(ctx context.Context, environmentID, configurationID string) ([]sourceControlBranchConfigurationDto, error) { + values := url.Values{} + values.Add("$filter", fmt.Sprintf("(_sourcecontrolconfigurationid_value eq %s)", configurationID)) + + return c.querySourceControlBranchConfigurations(ctx, environmentID, values) +} + +func (c *client) ListSourceControlBranchConfigurationsByPartition(ctx context.Context, environmentID, partitionID string) ([]sourceControlBranchConfigurationDto, error) { + values := url.Values{} + values.Add("partitionId", partitionID) + + return c.querySourceControlBranchConfigurations(ctx, environmentID, values) +} + +func (c *client) GetSolutionPartition(ctx context.Context, environmentID, solutionUniqueName string) (*solution.SolutionDto, error) { + return c.SolutionClient.GetSolutionUniqueName(ctx, environmentID, solutionUniqueName) +} + +func (c *client) ListGitOrganizations(ctx context.Context, environmentID string) ([]gitOrganizationDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + var organizations gitOrganizationArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/gitorganizations", nil), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &organizations) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + return organizations.Value, nil +} + +func (c *client) ListGitProjects(ctx context.Context, environmentID, organizationName string) ([]gitProjectDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + values := url.Values{} + values.Add("$filter", fmt.Sprintf("(organizationname eq '%s')", escapeODataString(organizationName))) + + var projects gitProjectArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/gitprojects", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &projects) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + return projects.Value, nil +} + +func (c *client) ListGitRepositories(ctx context.Context, environmentID, organizationName, projectName string) ([]gitRepositoryDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + values := url.Values{} + filters := []string{fmt.Sprintf("(organizationname eq '%s'", escapeODataString(organizationName))} + if strings.TrimSpace(projectName) != "" { + filters = append(filters, fmt.Sprintf("projectname eq '%s'", escapeODataString(projectName))) + } + values.Add("$filter", strings.Join(filters, " and ")+")") + + var repositories gitRepositoryArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/gitrepositories", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &repositories) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + return repositories.Value, nil +} + +func (c *client) GetGitRepositoryDefaultBranch(ctx context.Context, environmentID, organizationName, projectName, repositoryName string) (string, error) { + repositories, err := c.ListGitRepositories(ctx, environmentID, organizationName, projectName) + if err != nil { + return "", err + } + + for _, repository := range repositories { + if strings.EqualFold(repository.RepositoryName, repositoryName) { + defaultBranch := strings.TrimSpace(repository.DefaultBranch) + defaultBranch = strings.TrimPrefix(defaultBranch, "refs/heads/") + if defaultBranch == "" { + return "main", nil + } + return defaultBranch, nil + } + } + + return "", customerrors.WrapIntoProviderError(fmt.Errorf("git repository '%s' not found", repositoryName), customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "git repository not found") +} + +func (c *client) ListGitBranches(ctx context.Context, environmentID, organizationName, projectName, repositoryName string) ([]gitBranchDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + values := url.Values{} + filters := []string{fmt.Sprintf("(organizationname eq '%s'", escapeODataString(organizationName))} + if strings.TrimSpace(projectName) != "" { + filters = append(filters, fmt.Sprintf("projectname eq '%s'", escapeODataString(projectName))) + } + filters = append(filters, fmt.Sprintf("repositoryname eq '%s'", escapeODataString(repositoryName))) + values.Add("$filter", strings.Join(filters, " and ")+")") + + var branches gitBranchArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/gitbranches", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &branches) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + return branches.Value, nil +} + +func (c *client) GetUnmanagedSolutionByID(ctx context.Context, environmentID, solutionID string) (*unmanagedSolutionDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + values := url.Values{} + values.Add("$filter", fmt.Sprintf("(ismanaged eq false and solutionid eq %s)", solutionID)) + values.Add("$select", "solutionid,uniquename,friendlyname,ismanaged,isvisible,enabledforsourcecontrolintegration,version") + + var solutions unmanagedSolutionArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.2/solutions", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &solutions) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + if len(solutions.Value) == 0 { + baseErr := fmt.Errorf("unmanaged solution with id '%s' not found", solutionID) + return nil, customerrors.WrapIntoProviderError(baseErr, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), baseErr.Error()) + } + + return &solutions.Value[0], nil +} + +func (c *client) EnableEnvironmentScopeSolutions(ctx context.Context, environmentID string) error { + solutions, err := c.ListEnvironmentScopeSolutions(ctx, environmentID) + if err != nil { + return err + } + + for _, solutionRow := range solutions { + if solutionRow.EnabledForSourceControlIntegration { + continue + } + + if err := c.EnableSolutionSourceControlIntegration(ctx, environmentID, solutionRow.ID); err != nil { + return err + } + + if err := c.waitForEnvironmentScopeSolutionEnabled(ctx, environmentID, solutionRow.ID, solutionRow.UniqueName); err != nil { + return err + } + } + + return nil +} + +func (c *client) EnableSolutionSourceControlIntegration(ctx context.Context, environmentID, solutionID string) error { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.0/solutions(%s)", solutionID), nil) + resp, err := c.Api.Execute(ctx, nil, http.MethodPatch, apiURL, nil, updateSolutionSourceControlIntegrationDto{ + EnabledForSourceControlIntegration: true, + }, []int{http.StatusNoContent, http.StatusForbidden, http.StatusNotFound}, nil) + if err != nil { + return err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return err + } + return c.Api.HandleNotFoundResponse(resp) +} + +func (c *client) waitForEnvironmentScopeSolutionEnabled(ctx context.Context, environmentID, solutionID, solutionUniqueName string) error { + stableReads := 0 + + for { + solutionRow, err := c.GetUnmanagedSolutionByID(ctx, environmentID, solutionID) + if err != nil { + return err + } + + if solutionRow.EnabledForSourceControlIntegration { + preValidated, err := c.PreValidateGitComponents(ctx, environmentID, solutionUniqueName) + if err != nil { + return err + } + if preValidated { + stableReads++ + if stableReads >= stabilizedReadCountRequired { + return nil + } + } else { + stableReads = 0 + } + } else { + stableReads = 0 + } + + if err := c.Api.SleepWithContext(ctx, api.DefaultRetryAfter()); err != nil { + return err + } + } +} + +func (c *client) GetSourceControlIntegrationScope(ctx context.Context, environmentID string) (string, error) { + orgSettings, err := c.getOrganizationSettings(ctx, environmentID) + if err != nil { + return "", err + } + + scope := sourceControlIntegrationScopeFromOrgDbValue(extractOrgDbOrgSettingValue(orgSettings.OrgDbOrgSettings, "SourceControlIntegrationScope")) + if scope == "" { + return "", fmt.Errorf("source control integration scope for environment `%s` could not be determined from organization settings", environmentID) + } + + return scope, nil +} + +func (c *client) SetSourceControlIntegrationScope(ctx context.Context, environmentID, scope string) error { + orgSettings, err := c.getOrganizationSettings(ctx, environmentID) + if err != nil { + return err + } + + updatedOrgSettings := setOrgDbOrgSettingValue(orgSettings.OrgDbOrgSettings, "SourceControlIntegrationScope", sourceControlIntegrationScopeToOrgDbValue(scope)) + updateDTO := organizationSettingsDto{ + OrgDbOrgSettings: updatedOrgSettings, + } + + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return err + } + + apiURL := helpers.BuildDataverseApiUrl(environmentHost, fmt.Sprintf("/api/data/v9.0/organizations(%s)", orgSettings.OrganizationID), nil) + resp, err := c.Api.Execute(ctx, nil, http.MethodPatch, apiURL, nil, updateDTO, []int{http.StatusNoContent, http.StatusForbidden, http.StatusNotFound}, nil) + if err != nil { + return err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return err + } + return c.Api.HandleNotFoundResponse(resp) +} + +func (c *client) getOrganizationSettings(ctx context.Context, environmentID string) (*organizationSettingsDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + values := url.Values{} + values.Add("$select", "organizationid,orgdborgsettings") + + var organizations organizationSettingsArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/organizations", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &organizations) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + if len(organizations.Value) == 0 { + return nil, customerrors.WrapIntoProviderError(nil, customerrors.ErrorCode(constants.ERROR_OBJECT_NOT_FOUND), "organization settings not found") + } + + return &organizations.Value[0], nil +} + +func (c *client) querySourceControlBranchConfigurations(ctx context.Context, environmentID string, values url.Values) ([]sourceControlBranchConfigurationDto, error) { + environmentHost, err := c.EnvironmentClient.GetEnvironmentHostById(ctx, environmentID) + if err != nil { + return nil, err + } + + var branches sourceControlBranchConfigurationArrayDto + resp, err := c.Api.Execute(ctx, nil, http.MethodGet, helpers.BuildDataverseApiUrl(environmentHost, "/api/data/v9.0/sourcecontrolbranchconfigurations", values), nil, nil, []int{http.StatusOK, http.StatusForbidden, http.StatusNotFound}, &branches) + if err != nil { + return nil, err + } + if err := c.Api.HandleForbiddenResponse(resp); err != nil { + return nil, err + } + if err := c.Api.HandleNotFoundResponse(resp); err != nil { + return nil, err + } + + return branches.Value, nil +} + +func buildSourceControlConfigurationBindPath(configurationID string) string { + return fmt.Sprintf("/sourcecontrolconfigurations(%s)", configurationID) +} + +func buildSourceControlBranchConfigurationCompositeKeyPath(branchID, partitionID string) string { + return fmt.Sprintf("/api/data/v9.0/sourcecontrolbranchconfigurations(sourcecontrolbranchconfigurationid=%s,partitionid='%s')", branchID, partitionID) +} + +func escapeODataString(value string) string { + return strings.ReplaceAll(value, "'", "''") +} + +func sourceControlIntegrationScopeToOrgDbValue(scope string) string { + switch scope { + case scopeEnvironment: + return "EnvironmentScope" + default: + return "SolutionScope" + } +} + +func sourceControlIntegrationScopeFromOrgDbValue(value string) string { + switch strings.TrimSpace(value) { + case "EnvironmentScope": + return scopeEnvironment + case "SolutionScope": + return scopeSolution + default: + return "" + } +} + +func extractOrgDbOrgSettingValue(orgSettingsXML, name string) string { + if orgSettingsXML == "" { + return "" + } + + pattern := regexp.MustCompile(fmt.Sprintf(`<%s>([^<]*)`, regexp.QuoteMeta(name), regexp.QuoteMeta(name))) + matches := pattern.FindStringSubmatch(orgSettingsXML) + if len(matches) != 2 { + return "" + } + + return matches[1] +} + +func setOrgDbOrgSettingValue(orgSettingsXML, name, value string) string { + if strings.TrimSpace(orgSettingsXML) == "" { + return fmt.Sprintf("<%s>%s", name, value, name) + } + + pattern := regexp.MustCompile(fmt.Sprintf(`<%s>[^<]*`, regexp.QuoteMeta(name), regexp.QuoteMeta(name))) + replacement := fmt.Sprintf("<%s>%s", name, value, name) + if pattern.MatchString(orgSettingsXML) { + return pattern.ReplaceAllString(orgSettingsXML, replacement) + } + + return strings.Replace(orgSettingsXML, "", replacement+"", 1) +} + +func isEnvironmentScopeCandidateSolution(solutionRow unmanagedSolutionDto) bool { + if !solutionRow.IsVisible || solutionRow.IsManaged { + return false + } + + switch strings.ToLower(strings.TrimSpace(solutionRow.ID)) { + case commonDataServicesDefaultSolutionID, activeSolutionID, defaultSolutionID: + return false + } + + switch strings.ToLower(strings.TrimSpace(solutionRow.DisplayName)) { + case commonDataServicesDefaultSolutionName, defaultSolutionName: + return false + } + + return true +} diff --git a/internal/services/git_integration/api_git_integration_internal_test.go b/internal/services/git_integration/api_git_integration_internal_test.go new file mode 100644 index 000000000..be3717509 --- /dev/null +++ b/internal/services/git_integration/api_git_integration_internal_test.go @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "context" + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/microsoft/terraform-provider-power-platform/internal/api" + "github.com/microsoft/terraform-provider-power-platform/internal/config" + "github.com/stretchr/testify/require" +) + +func TestUnitDeleteSolutionGitBranch_UsesLookedUpBranchID(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("no responder found for %s %s", req.Method, req.URL) + }) + + deleted := false + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001\?%24expand=permissions%2Cproperties\.capacity%2Cproperties%2FbillingPolicy(%2Cproperties%2FcopilotPolicies)?&api-version=2023-06-01$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{ + "id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001", + "name": "00000000-0000-0000-0000-000000000001", + "type": "Microsoft.BusinessAppPlatform/scopes/admin/environments", + "location": "europe", + "properties": { + "displayName": "Test", + "linkedEnvironmentMetadata": { + "instanceUrl": "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/" + } + } + }`), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/sourcecontrolbranchconfigurations?partitionId=33333333-3333-3333-3333-333333333333", + func(req *http.Request) (*http.Response, error) { + if deleted { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"22222222-2222-2222-2222-222222222222","partitionid":"33333333-3333-3333-3333-333333333333","statuscode":0,"_sourcecontrolconfigurationid_value":"11111111-1111-1111-1111-111111111111"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolbranchconfigurations%28sourcecontrolbranchconfigurationid=22222222-2222-2222-2222-222222222222,partitionid=%2733333333-3333-3333-3333-333333333333%27%29$`), + func(req *http.Request) (*http.Response, error) { + deleted = true + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + cfg := &config.ProviderConfig{ + TestMode: true, + Urls: config.ProviderConfigUrls{ + BapiUrl: "api.bap.microsoft.com", + }, + } + apiClient := api.NewApiClientBase(cfg, api.NewAuthBase(cfg)) + client := newGitIntegrationClient(apiClient) + + err := client.DeleteSolutionGitBranch(context.Background(), "00000000-0000-0000-0000-000000000001", "11111111-1111-1111-1111-111111111111", "33333333-3333-3333-3333-333333333333") + require.NoError(t, err) + require.True(t, deleted) +} + +func TestUnitGetSourceControlIntegrationScope_RejectsUnknownScope(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("no responder found for %s %s", req.Method, req.URL) + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001\?%24expand=permissions%2Cproperties\.capacity%2Cproperties%2FbillingPolicy(%2Cproperties%2FcopilotPolicies)?&api-version=2023-06-01$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{ + "id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001", + "name": "00000000-0000-0000-0000-000000000001", + "type": "Microsoft.BusinessAppPlatform/scopes/admin/environments", + "location": "europe", + "properties": { + "displayName": "Test", + "linkedEnvironmentMetadata": { + "instanceUrl": "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/" + } + } + }`), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/organizations?%24select=organizationid%2Corgdborgsettings", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationid":"44444444-4444-4444-4444-444444444444","orgdborgsettings":"BrokenScope"}]}`), nil + }) + + cfg := &config.ProviderConfig{ + TestMode: true, + Urls: config.ProviderConfigUrls{ + BapiUrl: "api.bap.microsoft.com", + }, + } + apiClient := api.NewApiClientBase(cfg, api.NewAuthBase(cfg)) + client := newGitIntegrationClient(apiClient) + + _, err := client.GetSourceControlIntegrationScope(context.Background(), "00000000-0000-0000-0000-000000000001") + require.ErrorContains(t, err, "could not be determined") +} diff --git a/internal/services/git_integration/dto.go b/internal/services/git_integration/dto.go new file mode 100644 index 000000000..47883bdec --- /dev/null +++ b/internal/services/git_integration/dto.go @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +type gitOrganizationDto struct { + OrganizationName string `json:"organizationname,omitempty"` +} + +type gitOrganizationArrayDto struct { + Value []gitOrganizationDto `json:"value"` +} + +type gitProjectDto struct { + OrganizationName string `json:"organizationname,omitempty"` + ProjectName string `json:"projectname,omitempty"` +} + +type gitProjectArrayDto struct { + Value []gitProjectDto `json:"value"` +} + +type gitRepositoryDto struct { + OrganizationName string `json:"organizationname,omitempty"` + ProjectName string `json:"projectname,omitempty"` + RepositoryName string `json:"repositoryname,omitempty"` + DefaultBranch string `json:"defaultbranch,omitempty"` +} + +type gitRepositoryArrayDto struct { + Value []gitRepositoryDto `json:"value"` +} + +type gitBranchDto struct { + OrganizationName string `json:"organizationname,omitempty"` + ProjectName string `json:"projectname,omitempty"` + RepositoryName string `json:"repositoryname,omitempty"` + BranchName string `json:"branchname,omitempty"` + UpstreamBranchName string `json:"upstreambranchname,omitempty"` +} + +type gitBranchArrayDto struct { + Value []gitBranchDto `json:"value"` +} + +type unmanagedSolutionDto struct { + ID string `json:"solutionid,omitempty"` + UniqueName string `json:"uniquename,omitempty"` + DisplayName string `json:"friendlyname,omitempty"` + IsManaged bool `json:"ismanaged,omitempty"` + IsVisible bool `json:"isvisible,omitempty"` + EnabledForSourceControlIntegration bool `json:"enabledforsourcecontrolintegration,omitempty"` + Version string `json:"version,omitempty"` +} + +type unmanagedSolutionArrayDto struct { + Value []unmanagedSolutionDto `json:"value"` +} + +type organizationSettingsDto struct { + OrganizationID string `json:"organizationid,omitempty"` + OrgDbOrgSettings string `json:"orgdborgsettings,omitempty"` +} + +type organizationSettingsArrayDto struct { + Value []organizationSettingsDto `json:"value"` +} + +type sourceControlConfigurationDto struct { + ID string `json:"sourcecontrolconfigurationid,omitempty"` + Name string `json:"name,omitempty"` + OrganizationName string `json:"organizationname,omitempty"` + ProjectName string `json:"projectname,omitempty"` + RepositoryName string `json:"repositoryname,omitempty"` + GitProvider int `json:"gitprovider,omitempty"` +} + +type sourceControlConfigurationArrayDto struct { + Value []sourceControlConfigurationDto `json:"value"` +} + +type sourceControlBranchConfigurationDto struct { + ID string `json:"sourcecontrolbranchconfigurationid,omitempty"` + Name string `json:"name,omitempty"` + PartitionID string `json:"partitionid,omitempty"` + BranchName string `json:"branchname,omitempty"` + UpstreamBranchName string `json:"upstreambranchname,omitempty"` + RootFolderPath string `json:"rootfolderpath,omitempty"` + BranchSyncedCommitID string `json:"branchsyncedcommitid,omitempty"` + UpstreamBranchSyncedCommit string `json:"upstreambranchsyncedcommitid,omitempty"` + StatusCode int `json:"statuscode,omitempty"` + SourceControlConfiguration string `json:"_sourcecontrolconfigurationid_value,omitempty"` +} + +type sourceControlBranchConfigurationArrayDto struct { + Value []sourceControlBranchConfigurationDto `json:"value"` +} + +type createSourceControlConfigurationDto struct { + ID string `json:"sourcecontrolconfigurationid,omitempty"` + Name string `json:"name,omitempty"` + OrganizationName string `json:"organizationname,omitempty"` + ProjectName string `json:"projectname,omitempty"` + RepositoryName string `json:"repositoryname,omitempty"` + GitProvider int `json:"gitprovider,omitempty"` +} + +type updateSourceControlConfigurationDto struct { + Name string `json:"name,omitempty"` + OrganizationName string `json:"organizationname,omitempty"` + ProjectName string `json:"projectname,omitempty"` + RepositoryName string `json:"repositoryname,omitempty"` + GitProvider int `json:"gitprovider,omitempty"` +} + +type createSourceControlBranchConfigurationDto struct { + ID string `json:"sourcecontrolbranchconfigurationid,omitempty"` + Name string `json:"name,omitempty"` + PartitionID string `json:"partitionid,omitempty"` + BranchName string `json:"branchname,omitempty"` + UpstreamBranchName string `json:"upstreambranchname,omitempty"` + RootFolderPath string `json:"rootfolderpath,omitempty"` + SourceControlConfigurationBindID string `json:"sourcecontrolconfigurationid@odata.bind,omitempty"` +} + +type updateSourceControlBranchConfigurationDto struct { + Name string `json:"name,omitempty"` + BranchName string `json:"branchname,omitempty"` + UpstreamBranchName string `json:"upstreambranchname,omitempty"` + RootFolderPath string `json:"rootfolderpath,omitempty"` +} + +type disableSourceControlBranchConfigurationDto struct { + StatusCode int `json:"statuscode,omitempty"` +} + +type preValidateGitComponentsRequestDto struct { + SolutionUniqueName string `json:"SolutionUniqueName"` +} + +type preValidateGitComponentsResponseDto struct { + ValidationMessages string `json:"ValidationMessages,omitempty"` +} + +type updateSolutionSourceControlIntegrationDto struct { + EnabledForSourceControlIntegration bool `json:"enabledforsourcecontrolintegration,omitempty"` +} diff --git a/internal/services/git_integration/models.go b/internal/services/git_integration/models.go new file mode 100644 index 000000000..d95d82cc0 --- /dev/null +++ b/internal/services/git_integration/models.go @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/microsoft/terraform-provider-power-platform/internal/helpers" +) + +const ( + gitProviderAzureDevOps = "AzureDevOps" + scopeEnvironment = "Environment" + scopeSolution = "Solution" + rootPartitionID = "00000000-0000-0000-0000-000000000000" + rootFolderPath = "dataverse" + commonDataServicesDefaultSolutionID = "00000001-0000-0000-0001-00000000009b" + activeSolutionID = "fd140aae-4df4-11dd-bd17-0019b9312238" + defaultSolutionID = "fd140aaf-4df4-11dd-bd17-0019b9312238" + commonDataServicesDefaultSolutionName = "common data services default solution" + defaultSolutionName = "default solution" +) + +type EnvironmentGitIntegrationResource struct { + helpers.TypeInfo + GitIntegrationClient client +} + +type EnvironmentGitIntegrationResourceModel struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + ID types.String `tfsdk:"id"` + EnvironmentID types.String `tfsdk:"environment_id"` + GitProvider types.String `tfsdk:"git_provider"` + Scope types.String `tfsdk:"scope"` + OrganizationName types.String `tfsdk:"organization_name"` + ProjectName types.String `tfsdk:"project_name"` + RepositoryName types.String `tfsdk:"repository_name"` +} + +type SolutionGitBranchResource struct { + helpers.TypeInfo + GitIntegrationClient client +} + +type SolutionGitBranchResourceModel struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + ID types.String `tfsdk:"id"` + EnvironmentID types.String `tfsdk:"environment_id"` + GitIntegrationID types.String `tfsdk:"git_integration_id"` + SolutionID types.String `tfsdk:"solution_id"` + BranchName types.String `tfsdk:"branch_name"` + UpstreamBranchName types.String `tfsdk:"upstream_branch_name"` + RootFolderPath types.String `tfsdk:"root_folder_path"` +} + +func gitProviderToInt(value string) int { + switch strings.TrimSpace(value) { + case "", gitProviderAzureDevOps: + return 0 + default: + return -1 + } +} + +func gitProviderFromInt(value int) string { + switch value { + case 0: + return gitProviderAzureDevOps + default: + return "" + } +} + +func convertSourceControlConfigurationDtoToModel(environmentID, scope string, dto sourceControlConfigurationDto) EnvironmentGitIntegrationResourceModel { + return EnvironmentGitIntegrationResourceModel{ + ID: types.StringValue(dto.ID), + EnvironmentID: types.StringValue(environmentID), + GitProvider: types.StringValue(gitProviderFromInt(dto.GitProvider)), + Scope: types.StringValue(scope), + OrganizationName: types.StringValue(dto.OrganizationName), + ProjectName: types.StringValue(dto.ProjectName), + RepositoryName: types.StringValue(dto.RepositoryName), + } +} + +func convertSourceControlBranchConfigurationDtoToModel(environmentID string, dto sourceControlBranchConfigurationDto) SolutionGitBranchResourceModel { + return SolutionGitBranchResourceModel{ + ID: types.StringValue(dto.ID), + EnvironmentID: types.StringValue(environmentID), + GitIntegrationID: types.StringValue(dto.SourceControlConfiguration), + SolutionID: types.StringValue(buildSolutionReference(environmentID, dto.PartitionID)), + BranchName: types.StringValue(dto.BranchName), + UpstreamBranchName: types.StringValue(dto.UpstreamBranchName), + RootFolderPath: types.StringValue(dto.RootFolderPath), + } +} diff --git a/internal/services/git_integration/models_internal_test.go b/internal/services/git_integration/models_internal_test.go new file mode 100644 index 000000000..d159ffa6b --- /dev/null +++ b/internal/services/git_integration/models_internal_test.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnitConvertSourceControlConfigurationDtoToModel_UsesConcreteProjectNameValue(t *testing.T) { + model := convertSourceControlConfigurationDtoToModel("00000000-0000-0000-0000-000000000001", scopeSolution, sourceControlConfigurationDto{ + ID: "11111111-1111-1111-1111-111111111111", + GitProvider: 0, + OrganizationName: "example-org", + ProjectName: "", + RepositoryName: "example-repo", + }) + + require.False(t, model.ProjectName.IsNull()) + require.Equal(t, "", model.ProjectName.ValueString()) +} diff --git a/internal/services/git_integration/resource_environment_git_integration.go b/internal/services/git_integration/resource_environment_git_integration.go new file mode 100644 index 000000000..cfd3a7113 --- /dev/null +++ b/internal/services/git_integration/resource_environment_git_integration.go @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/schema/validator" + "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 = &EnvironmentGitIntegrationResource{} +var _ resource.ResourceWithValidateConfig = &EnvironmentGitIntegrationResource{} +var _ resource.ResourceWithImportState = &EnvironmentGitIntegrationResource{} + +func NewEnvironmentGitIntegrationResource() resource.Resource { + return &EnvironmentGitIntegrationResource{ + TypeInfo: helpers.TypeInfo{ + TypeName: "environment_git_integration", + }, + } +} + +func (r *EnvironmentGitIntegrationResource) 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 *EnvironmentGitIntegrationResource) 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: "Manages the environment-level Dataverse Git repository binding. This maps to the documented `sourcecontrolconfiguration` Dataverse table and stores the repository connection metadata for an environment.\n\nKnown limitation: the underlying Power Platform Git integration bootstrap currently requires delegated user principal authentication with Azure DevOps access. Service principal, app-only, and OIDC pipeline identities are not currently supported by the backing Dataverse Git integration flow.", + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Read: true, + Update: true, + Delete: true, + }), + "id": schema.StringAttribute{ + MarkdownDescription: "Unique identifier of the Dataverse source control configuration.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "environment_id": schema.StringAttribute{ + MarkdownDescription: "Environment ID of the Dataverse environment where the Git repository binding will be created.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "git_provider": schema.StringAttribute{ + MarkdownDescription: "Git provider for the repository binding. Supported value is `AzureDevOps`.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(gitProviderAzureDevOps), + }, + }, + "scope": schema.StringAttribute{ + MarkdownDescription: "Source control integration scope for the environment. Use `Solution` for solution-level branch bindings and `Environment` for an environment-level binding. In `Environment` scope, the provider manages the root branch binding and proactively enables eligible visible unmanaged solutions in the environment while excluding platform-owned default solutions.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(scopeEnvironment, scopeSolution), + }, + }, + "organization_name": schema.StringAttribute{ + MarkdownDescription: "Organization or owner name for the configured Git provider.", + Required: true, + }, + "project_name": schema.StringAttribute{ + MarkdownDescription: "Project name for the Azure DevOps repository binding.", + Required: true, + }, + "repository_name": schema.StringAttribute{ + MarkdownDescription: "Repository name that the environment will bind to.", + Required: true, + }, + }, + } +} + +func (r *EnvironmentGitIntegrationResource) 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 + } + + providerClient, 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.GitIntegrationClient = newGitIntegrationClient(providerClient.Api) +} + +func (r *EnvironmentGitIntegrationResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var projectName types.String + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("project_name"), &projectName)...) + if resp.Diagnostics.HasError() { + return + } + + if projectName.IsUnknown() { + return + } + + if projectName.IsNull() || projectName.ValueString() == "" { + resp.Diagnostics.AddAttributeError( + path.Root("project_name"), + "Missing project_name for AzureDevOps", + "The `project_name` attribute is required when `git_provider` is `AzureDevOps`.", + ) + } +} + +func (r *EnvironmentGitIntegrationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan EnvironmentGitIntegrationResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + createDTO := createSourceControlConfigurationDto{ + ID: uuid.NewString(), + Name: "", + OrganizationName: plan.OrganizationName.ValueString(), + ProjectName: plan.ProjectName.ValueString(), + RepositoryName: plan.RepositoryName.ValueString(), + GitProvider: gitProviderToInt(plan.GitProvider.ValueString()), + } + + r.validateRemoteConfiguration(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if err := r.GitIntegrationClient.SetSourceControlIntegrationScope(ctx, plan.EnvironmentID.ValueString(), plan.Scope.ValueString()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when setting scope for %s", r.FullTypeName()), err.Error()) + return + } + + created, err := r.GitIntegrationClient.CreateEnvironmentGitIntegration(ctx, plan.EnvironmentID.ValueString(), createDTO) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating %s", r.FullTypeName()), err.Error()) + return + } + + created, err = r.GitIntegrationClient.WaitForEnvironmentGitIntegrationReady(ctx, plan.EnvironmentID.ValueString(), created.ID) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when waiting for %s to stabilize", r.FullTypeName()), err.Error()) + return + } + + if plan.Scope.ValueString() == scopeEnvironment { + if err := r.GitIntegrationClient.EnsureRootBranchConfiguration(ctx, plan.EnvironmentID.ValueString(), created.ID, created.OrganizationName, created.ProjectName, created.RepositoryName); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating the root Git binding for %s", r.FullTypeName()), err.Error()) + return + } + + if err := r.GitIntegrationClient.EnableEnvironmentScopeSolutions(ctx, plan.EnvironmentID.ValueString()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when enabling environment-scoped solutions for %s", r.FullTypeName()), err.Error()) + return + } + } + + state := convertSourceControlConfigurationDtoToModel(plan.EnvironmentID.ValueString(), plan.Scope.ValueString(), *created) + state.Timeouts = plan.Timeouts + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *EnvironmentGitIntegrationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state EnvironmentGitIntegrationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + dto, err := r.GitIntegrationClient.GetEnvironmentGitIntegration(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 + } + + scope, err := r.GitIntegrationClient.GetSourceControlIntegrationScope(ctx, state.EnvironmentID.ValueString()) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading scope for %s", r.FullTypeName()), err.Error()) + return + } + + newState := convertSourceControlConfigurationDtoToModel(state.EnvironmentID.ValueString(), scope, *dto) + newState.Timeouts = state.Timeouts + + resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) +} + +func (r *EnvironmentGitIntegrationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan EnvironmentGitIntegrationResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + var state EnvironmentGitIntegrationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + updateDTO := updateSourceControlConfigurationDto{ + Name: plan.RepositoryName.ValueString(), + OrganizationName: plan.OrganizationName.ValueString(), + ProjectName: plan.ProjectName.ValueString(), + RepositoryName: plan.RepositoryName.ValueString(), + GitProvider: gitProviderToInt(plan.GitProvider.ValueString()), + } + + r.validateRemoteConfiguration(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if err := r.GitIntegrationClient.SetSourceControlIntegrationScope(ctx, plan.EnvironmentID.ValueString(), plan.Scope.ValueString()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when setting scope for %s", r.FullTypeName()), err.Error()) + return + } + + updated, err := r.GitIntegrationClient.UpdateEnvironmentGitIntegration(ctx, plan.EnvironmentID.ValueString(), state.ID.ValueString(), updateDTO) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when updating %s", r.FullTypeName()), err.Error()) + return + } + + updated, err = r.GitIntegrationClient.WaitForEnvironmentGitIntegrationReady(ctx, plan.EnvironmentID.ValueString(), updated.ID) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when waiting for %s to stabilize", r.FullTypeName()), err.Error()) + return + } + + if plan.Scope.ValueString() == scopeEnvironment { + if err := r.GitIntegrationClient.EnsureRootBranchConfiguration(ctx, plan.EnvironmentID.ValueString(), updated.ID, updated.OrganizationName, updated.ProjectName, updated.RepositoryName); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating the root Git binding for %s", r.FullTypeName()), err.Error()) + return + } + + if err := r.GitIntegrationClient.EnableEnvironmentScopeSolutions(ctx, plan.EnvironmentID.ValueString()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when enabling environment-scoped solutions for %s", r.FullTypeName()), err.Error()) + return + } + } + + newState := convertSourceControlConfigurationDtoToModel(plan.EnvironmentID.ValueString(), plan.Scope.ValueString(), *updated) + newState.Timeouts = plan.Timeouts + + resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) +} + +func (r *EnvironmentGitIntegrationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state EnvironmentGitIntegrationResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.GitIntegrationClient.DeleteEnvironmentGitIntegration(ctx, state.EnvironmentID.ValueString(), state.ID.ValueString()); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when deleting %s", r.FullTypeName()), err.Error()) + } +} + +func (r *EnvironmentGitIntegrationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + configurations, err := r.GitIntegrationClient.ListEnvironmentGitIntegrations(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError( + "Client error when importing environment git integration", + err.Error(), + ) + return + } + + if len(configurations) == 0 { + resp.Diagnostics.AddError( + "Environment git integration not found", + fmt.Sprintf("No Dataverse source control configuration was found in environment '%s'. Import expects the environment ID of an environment that already has a Git integration.", req.ID), + ) + return + } + + if len(configurations) > 1 { + resp.Diagnostics.AddError( + "Multiple environment git integrations found", + fmt.Sprintf("Expected exactly one Dataverse source control configuration in environment '%s', found %d.", req.ID, len(configurations)), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("environment_id"), req.ID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), configurations[0].ID)...) +} diff --git a/internal/services/git_integration/resource_environment_git_integration_test.go b/internal/services/git_integration/resource_environment_git_integration_test.go new file mode 100644 index 000000000..c9effd9e7 --- /dev/null +++ b/internal/services/git_integration/resource_environment_git_integration_test.go @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration_test + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/jarcoal/httpmock" + "github.com/microsoft/terraform-provider-power-platform/internal/mocks" +) + +func TestUnitEnvironmentGitIntegrationResource_Validate_Create_And_Update(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + updatedConfiguration := false + rootBranchCreated := false + configurationImplicitlyDeleted := false + environmentScopeSolutionPatches := 0 + environmentScopeEnabled := map[string]bool{ + "33333333-3333-3333-3333-333333333333": false, + "44444444-4444-4444-4444-444444444444": false, + } + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001\?%24expand=permissions%2Cproperties\.capacity%2Cproperties%2FbillingPolicy(%2Cproperties%2FcopilotPolicies)?&api-version=2023-06-01$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/shared/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.0/gitorganizations", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationname":"example-org"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/organizations(\?.*)?$`), + func(req *http.Request) (*http.Response, error) { + if !updatedConfiguration { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationid":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","orgdborgsettings":"SolutionScope"}]}`), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationid":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","orgdborgsettings":"EnvironmentScope"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/gitprojects\?%24filter=%28organizationname\+eq\+%27example-org%27%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationname":"example-org","projectname":"example-project"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/gitrepositories\?%24filter=.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo","defaultbranch":"refs/heads/main"},{"organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo-updated","defaultbranch":"refs/heads/main"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions\?.*ismanaged\+eq\+false.*isvisible\+eq\+true.*enabledforsourcecontrolintegration.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, fmt.Sprintf(`{"value":[ +{"solutionid":"00000001-0000-0000-0001-00000000009b","uniquename":"common-default","friendlyname":"Common Data Services Default Solution","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":false}, +{"solutionid":"fd140aaf-4df4-11dd-bd17-0019b9312238","uniquename":"Default","friendlyname":"Default Solution","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":false}, +{"solutionid":"33333333-3333-3333-3333-333333333333","uniquename":"solution-one","friendlyname":"solution-one","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":%t}, +{"solutionid":"44444444-4444-4444-4444-444444444444","uniquename":"solution-two","friendlyname":"solution-two","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":%t} +]}`, environmentScopeEnabled["33333333-3333-3333-3333-333333333333"], environmentScopeEnabled["44444444-4444-4444-4444-444444444444"])), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions\?.*solutionid\+eq\+33333333-3333-3333-3333-333333333333.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, fmt.Sprintf(`{"value":[{"solutionid":"33333333-3333-3333-3333-333333333333","uniquename":"solution-one","friendlyname":"solution-one","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":%t,"version":"1.0.0.0"}]}`, environmentScopeEnabled["33333333-3333-3333-3333-333333333333"])), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions\?.*solutionid\+eq\+44444444-4444-4444-4444-444444444444.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, fmt.Sprintf(`{"value":[{"solutionid":"44444444-4444-4444-4444-444444444444","uniquename":"solution-two","friendlyname":"solution-two","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":%t,"version":"1.0.0.0"}]}`, environmentScopeEnabled["44444444-4444-4444-4444-444444444444"])), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/sourcecontrolconfigurations", + 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.0/sourcecontrolconfigurations", + func(req *http.Request) (*http.Response, error) { + if configurationImplicitlyDeleted { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + + if !updatedConfiguration { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolconfigurationid":"11111111-1111-1111-1111-111111111111","organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo","gitprovider":0}]}`), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolconfigurationid":"11111111-1111-1111-1111-111111111111","organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo-updated","gitprovider":0}]}`), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/sourcecontrolbranchconfigurations", + func(req *http.Request) (*http.Response, error) { + rootBranchCreated = true + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolconfigurations%2811111111-1111-1111-1111-111111111111%29$`), + func(req *http.Request) (*http.Response, error) { + updatedConfiguration = true + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/organizations%28aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa%29$`), + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + bodyText := string(body) + if strings.Contains(bodyText, "organizationid") { + return nil, fmt.Errorf("organization scope patch unexpectedly included organizationid: %s", bodyText) + } + if !strings.Contains(bodyText, "SourceControlIntegrationScope") { + return nil, fmt.Errorf("organization scope patch missing SourceControlIntegrationScope: %s", bodyText) + } + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/solutions(?:%28|\()(33333333-3333-3333-3333-333333333333|44444444-4444-4444-4444-444444444444)(?:%29|\))$`), + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + bodyText := string(body) + if !strings.Contains(bodyText, `"enabledforsourcecontrolintegration":true`) { + return nil, fmt.Errorf("solution enablement patch did not send boolean true: %s", bodyText) + } + matched := false + for _, id := range []string{ + "33333333-3333-3333-3333-333333333333", + "44444444-4444-4444-4444-444444444444", + } { + if strings.Contains(req.URL.String(), id) { + environmentScopeEnabled[id] = true + environmentScopeSolutionPatches++ + matched = true + break + } + } + if !matched { + return nil, fmt.Errorf("unexpected solution enablement URL: %s", req.URL.String()) + } + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolconfigurations%28[0-9a-f-]{36}%29$`), + func(req *http.Request) (*http.Response, error) { + if configurationImplicitlyDeleted { + return httpmock.NewStringResponse(http.StatusNotFound, `{"error":{"code":"0x80040217","message":"source control configuration not found"}}`), nil + } + + if !updatedConfiguration { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json").String()), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{ + "sourcecontrolconfigurationid":"11111111-1111-1111-1111-111111111111", + "name":"", + "organizationname":"example-org", + "projectname":"example-project", + "repositoryname":"example-repo-updated", + "gitprovider":0 + }`), nil + }) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/sourcecontrolbranchconfigurations?partitionId=00000000-0000-0000-0000-000000000000", + func(req *http.Request) (*http.Response, error) { + if !rootBranchCreated { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"22222222-2222-2222-2222-222222222222","partitionid":"00000000-0000-0000-0000-000000000000","branchname":"main","upstreambranchname":"main","rootfolderpath":"dataverse","branchsyncedcommitid":"abc123","upstreambranchsyncedcommitid":"abc123","statuscode":0,"_sourcecontrolconfigurationid_value":"11111111-1111-1111-1111-111111111111"}]}`), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/PreValidateGitComponents", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"ValidationMessages":""}`), nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolbranchconfigurations%28sourcecontrolbranchconfigurationid=22222222-2222-2222-2222-222222222222,partitionid=%2700000000-0000-0000-0000-000000000000%27%29$`), + func(req *http.Request) (*http.Response, error) { + rootBranchCreated = false + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterRegexpResponder("DELETE", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolconfigurations%2811111111-1111-1111-1111-111111111111%29$`), + func(req *http.Request) (*http.Response, error) { + configurationImplicitlyDeleted = true + return httpmock.NewStringResponse(http.StatusBadRequest, `{"error":{"code":"0x80040265","message":"Existing source control configurations can't be deleted."}}`), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "powerplatform_environment_git_integration" "test" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_provider = "AzureDevOps" + scope = "Solution" + organization_name = "example-org" + project_name = "example-project" + repository_name = "example-repo" +} +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "git_provider", "AzureDevOps"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "scope", "Solution"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "organization_name", "example-org"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "project_name", "example-project"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "repository_name", "example-repo"), + ), + }, + { + Config: ` +resource "powerplatform_environment_git_integration" "test" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_provider = "AzureDevOps" + scope = "Environment" + organization_name = "example-org" + project_name = "example-project" + repository_name = "example-repo-updated" +} +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "git_provider", "AzureDevOps"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "scope", "Environment"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "organization_name", "example-org"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "project_name", "example-project"), + resource.TestCheckResourceAttr("powerplatform_environment_git_integration.test", "repository_name", "example-repo-updated"), + func(_ *terraform.State) error { + if environmentScopeSolutionPatches != 2 { + return fmt.Errorf("expected 2 environment-scope solution enablement patches, got %d", environmentScopeSolutionPatches) + } + return nil + }, + ), + }, + { + ResourceName: "powerplatform_environment_git_integration.test", + ImportState: true, + ImportStateVerify: true, + ImportStateId: "00000000-0000-0000-0000-000000000001", + }, + }, + }) +} diff --git a/internal/services/git_integration/resource_environment_git_integration_validateconfig_test.go b/internal/services/git_integration/resource_environment_git_integration_validateconfig_test.go new file mode 100644 index 000000000..63397872f --- /dev/null +++ b/internal/services/git_integration/resource_environment_git_integration_validateconfig_test.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/microsoft/terraform-provider-power-platform/internal/mocks" +) + +func TestUnitEnvironmentGitIntegrationResource_ValidateConfig_AllowsUnknownProjectName(t *testing.T) { + t.Parallel() + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + PlanOnly: true, + ExpectNonEmptyPlan: true, + Config: ` +resource "powerplatform_environment_git_integration" "seed" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_provider = "AzureDevOps" + scope = "Solution" + organization_name = "example-org" + project_name = "example-project" + repository_name = "example-repo" +} + +resource "powerplatform_environment_git_integration" "test" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_provider = "AzureDevOps" + scope = "Solution" + organization_name = "example-org" + project_name = powerplatform_environment_git_integration.seed.id + repository_name = "example-repo" +} +`, + }, + }, + }) +} diff --git a/internal/services/git_integration/resource_solution_git_branch.go b/internal/services/git_integration/resource_solution_git_branch.go new file mode 100644 index 000000000..cd0465a04 --- /dev/null +++ b/internal/services/git_integration/resource_solution_git_branch.go @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/schema/validator" + "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 = &SolutionGitBranchResource{} +var _ resource.ResourceWithImportState = &SolutionGitBranchResource{} + +func NewSolutionGitBranchResource() resource.Resource { + return &SolutionGitBranchResource{ + TypeInfo: helpers.TypeInfo{ + TypeName: "solution_git_branch", + }, + } +} + +func (r *SolutionGitBranchResource) 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 *SolutionGitBranchResource) 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: "Manages a solution-level Dataverse Git branch binding. This maps to the documented `sourcecontrolbranchconfiguration` Dataverse table and links a solution partition to a branch and folder beneath an environment Git integration.\n\nKnown limitation: the underlying Power Platform Git integration bootstrap currently requires delegated user principal authentication with Azure DevOps access. Service principal, app-only, and OIDC pipeline identities are not currently supported by the backing Dataverse Git integration flow.", + Attributes: map[string]schema.Attribute{ + "timeouts": timeouts.Attributes(ctx, timeouts.Opts{ + Create: true, + Read: true, + Update: true, + Delete: true, + }), + "id": schema.StringAttribute{ + MarkdownDescription: "Unique identifier of the Dataverse source control branch configuration.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "environment_id": schema.StringAttribute{ + MarkdownDescription: "Environment ID of the Dataverse environment where the branch binding exists.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "git_integration_id": schema.StringAttribute{ + MarkdownDescription: "ID of the parent `powerplatform_environment_git_integration` resource.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "solution_id": schema.StringAttribute{ + MarkdownDescription: "ID of the existing `powerplatform_solution` resource to bind to the Git branch. This must use the provider solution ID format for the same environment.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "branch_name": schema.StringAttribute{ + MarkdownDescription: "Branch name to bind the solution partition to.", + Required: true, + }, + "upstream_branch_name": schema.StringAttribute{ + MarkdownDescription: "Upstream branch name. When omitted, the provider will use the same value as `branch_name`.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "root_folder_path": schema.StringAttribute{ + MarkdownDescription: "Repository folder path that stores the solution's files.", + Required: true, + }, + }, + } +} + +func (r *SolutionGitBranchResource) 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 + } + + providerClient, 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.GitIntegrationClient = newGitIntegrationClient(providerClient.Api) +} + +func (r *SolutionGitBranchResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan SolutionGitBranchResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + upstreamBranchName := plan.UpstreamBranchName.ValueString() + if upstreamBranchName == "" { + upstreamBranchName = plan.BranchName.ValueString() + } + + solutionID := r.validateRemoteConfiguration(ctx, plan, "", &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + solutionDetails, err := r.GitIntegrationClient.GetUnmanagedSolutionByID(ctx, plan.EnvironmentID.ValueString(), solutionID) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when resolving solution metadata for %s", r.FullTypeName()), err.Error()) + return + } + + createDTO := createSourceControlBranchConfigurationDto{ + Name: "", + PartitionID: solutionID, + BranchName: plan.BranchName.ValueString(), + UpstreamBranchName: upstreamBranchName, + RootFolderPath: plan.RootFolderPath.ValueString(), + SourceControlConfigurationBindID: buildSourceControlConfigurationBindPath(plan.GitIntegrationID.ValueString()), + } + + created, err := r.GitIntegrationClient.CreateSolutionGitBranch(ctx, plan.EnvironmentID.ValueString(), solutionDetails.UniqueName, createDTO) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when creating %s", r.FullTypeName()), err.Error()) + return + } + + state := convertSourceControlBranchConfigurationDtoToModel(plan.EnvironmentID.ValueString(), *created) + state.Timeouts = plan.Timeouts + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *SolutionGitBranchResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state SolutionGitBranchResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + solutionID, err := normalizeSolutionID(state.EnvironmentID.ValueString(), state.SolutionID.ValueString()) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when reading %s", r.FullTypeName()), err.Error()) + return + } + + dto, err := r.GitIntegrationClient.FindSolutionGitBranchByPartition(ctx, state.EnvironmentID.ValueString(), state.GitIntegrationID.ValueString(), solutionID) + 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 + } + + newState := convertSourceControlBranchConfigurationDtoToModel(state.EnvironmentID.ValueString(), *dto) + newState.Timeouts = state.Timeouts + + resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) +} + +func (r *SolutionGitBranchResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var plan SolutionGitBranchResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + var state SolutionGitBranchResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + upstreamBranchName := plan.UpstreamBranchName.ValueString() + if upstreamBranchName == "" { + upstreamBranchName = plan.BranchName.ValueString() + } + + solutionID := r.validateRemoteConfiguration(ctx, plan, state.ID.ValueString(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + updateDTO := updateSourceControlBranchConfigurationDto{ + Name: solutionID, + BranchName: plan.BranchName.ValueString(), + UpstreamBranchName: upstreamBranchName, + RootFolderPath: plan.RootFolderPath.ValueString(), + } + + updated, err := r.GitIntegrationClient.UpdateSolutionGitBranch(ctx, plan.EnvironmentID.ValueString(), state.ID.ValueString(), plan.GitIntegrationID.ValueString(), solutionID, updateDTO) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when updating %s", r.FullTypeName()), err.Error()) + return + } + + newState := convertSourceControlBranchConfigurationDtoToModel(plan.EnvironmentID.ValueString(), *updated) + newState.Timeouts = plan.Timeouts + + resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...) +} + +func (r *SolutionGitBranchResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + var state SolutionGitBranchResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + solutionID, err := normalizeSolutionID(state.EnvironmentID.ValueString(), state.SolutionID.ValueString()) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when deleting %s", r.FullTypeName()), err.Error()) + return + } + + if err := r.GitIntegrationClient.DeleteSolutionGitBranch(ctx, state.EnvironmentID.ValueString(), state.GitIntegrationID.ValueString(), solutionID); err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Client error when deleting %s", r.FullTypeName()), err.Error()) + } +} + +func (r *SolutionGitBranchResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + ctx, exitContext := helpers.EnterRequestContext(ctx, r.TypeInfo, req) + defer exitContext() + + idParts := strings.SplitN(req.ID, "/", 3) + if len(idParts) != 3 { + resp.Diagnostics.AddError( + "Invalid import ID", + fmt.Sprintf("Expected import ID in format 'environment_id/git_integration_id/solution_id', got '%s'", req.ID), + ) + return + } + + solutionID := idParts[2] + if !strings.Contains(solutionID, "_") { + solutionID = buildSolutionReference(idParts[0], solutionID) + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("environment_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("git_integration_id"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("solution_id"), solutionID)...) +} diff --git a/internal/services/git_integration/resource_solution_git_branch_test.go b/internal/services/git_integration/resource_solution_git_branch_test.go new file mode 100644 index 000000000..a7eb772f6 --- /dev/null +++ b/internal/services/git_integration/resource_solution_git_branch_test.go @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration_test + +import ( + "encoding/json" + "io" + "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 TestUnitSolutionGitBranchResource_Validate_Create_And_Update(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + createdBranch := false + rootBranchCreated := false + updatedBranch := false + deletedBranch := false + deleteReadCount := 0 + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001\?%24expand=permissions%2Cproperties\.capacity%2Cproperties%2FbillingPolicy(%2Cproperties%2FcopilotPolicies)?&api-version=2023-06-01$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/shared/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions\?.*solutionid\+eq\+33333333-3333-3333-3333-333333333333.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"solutionid":"33333333-3333-3333-3333-333333333333","uniquename":"sample_solution","friendlyname":"Sample Solution","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":false,"version":"1.0.0.0"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/organizations(\?.*)?$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationid":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","orgdborgsettings":"SolutionScope"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/gitbranches\?%24filter=.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo","branchname":"main"},{"organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo","branchname":"develop"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/gitrepositories\?%24filter=.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo","defaultbranch":"refs/heads/main"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolconfigurations%2811111111-1111-1111-1111-111111111111%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json").String()), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/sourcecontrolbranchconfigurations", + func(req *http.Request) (*http.Response, error) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + body := map[string]any{} + if err := json.Unmarshal(bodyBytes, &body); err != nil { + return nil, err + } + + switch body["partitionid"] { + case "33333333-3333-3333-3333-333333333333": + createdBranch = true + case "00000000-0000-0000-0000-000000000000": + rootBranchCreated = true + default: + return httpmock.NewStringResponse(http.StatusBadRequest, ""), nil + } + + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolbranchconfigurations\?partitionId=33333333-3333-3333-3333-333333333333$`), + func(req *http.Request) (*http.Response, error) { + if !createdBranch { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + + if deletedBranch { + deleteReadCount++ + if deleteReadCount == 1 { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"22222222-2222-2222-2222-222222222222","partitionid":"33333333-3333-3333-3333-333333333333","statuscode":1}]}`), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + + if !updatedBranch { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"22222222-2222-2222-2222-222222222222","name":"33333333-3333-3333-3333-333333333333","partitionid":"33333333-3333-3333-3333-333333333333","branchname":"main","upstreambranchname":"main","rootfolderpath":"solutions/sample-solution","branchsyncedcommitid":"abc123","upstreambranchsyncedcommitid":"abc123","statuscode":0,"_sourcecontrolconfigurationid_value":"11111111-1111-1111-1111-111111111111"}]}`), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"22222222-2222-2222-2222-222222222222","name":"33333333-3333-3333-3333-333333333333","partitionid":"33333333-3333-3333-3333-333333333333","branchname":"develop","upstreambranchname":"main","rootfolderpath":"solutions/sample-solution-updated","branchsyncedcommitid":"def456","upstreambranchsyncedcommitid":"def456","statuscode":0,"_sourcecontrolconfigurationid_value":"11111111-1111-1111-1111-111111111111"}]}`), nil + }, + ) + + httpmock.RegisterResponder("GET", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/sourcecontrolbranchconfigurations?partitionId=00000000-0000-0000-0000-000000000000", + func(req *http.Request) (*http.Response, error) { + if !rootBranchCreated { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[]}`), nil + } + + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"44444444-4444-4444-4444-444444444444","partitionid":"00000000-0000-0000-0000-000000000000","branchname":"main","upstreambranchname":"main","rootfolderpath":"dataverse","branchsyncedcommitid":"abc123","upstreambranchsyncedcommitid":"abc123","statuscode":0,"_sourcecontrolconfigurationid_value":"11111111-1111-1111-1111-111111111111"}]}`), nil + }) + + httpmock.RegisterResponder("POST", "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com/api/data/v9.0/PreValidateGitComponents", + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"ValidationMessages":""}`), nil + }) + + httpmock.RegisterRegexpResponder("PATCH", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolbranchconfigurations%28sourcecontrolbranchconfigurationid=22222222-2222-2222-2222-222222222222,partitionid=%2733333333-3333-3333-3333-333333333333%27%29$`), + func(req *http.Request) (*http.Response, error) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + body := map[string]any{} + if err := json.Unmarshal(bodyBytes, &body); err != nil { + return nil, err + } + + if body["statuscode"] == float64(1) { + deletedBranch = true + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + } + + updatedBranch = true + return httpmock.NewStringResponse(http.StatusNoContent, ""), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "powerplatform_solution_git_branch" "sample" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_integration_id = "11111111-1111-1111-1111-111111111111" + solution_id = "00000000-0000-0000-0000-000000000001_33333333-3333-3333-3333-333333333333" + branch_name = "main" + root_folder_path = "solutions/sample-solution" +} +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "git_integration_id", "11111111-1111-1111-1111-111111111111"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "solution_id", "00000000-0000-0000-0000-000000000001_33333333-3333-3333-3333-333333333333"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "branch_name", "main"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "upstream_branch_name", "main"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "root_folder_path", "solutions/sample-solution"), + ), + }, + { + Config: ` +resource "powerplatform_solution_git_branch" "sample" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_integration_id = "11111111-1111-1111-1111-111111111111" + solution_id = "00000000-0000-0000-0000-000000000001_33333333-3333-3333-3333-333333333333" + branch_name = "develop" + upstream_branch_name = "main" + root_folder_path = "solutions/sample-solution-updated" +} +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "solution_id", "00000000-0000-0000-0000-000000000001_33333333-3333-3333-3333-333333333333"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "branch_name", "develop"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "upstream_branch_name", "main"), + resource.TestCheckResourceAttr("powerplatform_solution_git_branch.sample", "root_folder_path", "solutions/sample-solution-updated"), + ), + }, + { + ResourceName: "powerplatform_solution_git_branch.sample", + ImportState: true, + ImportStateVerify: true, + ImportStateId: "00000000-0000-0000-0000-000000000001/11111111-1111-1111-1111-111111111111/33333333-3333-3333-3333-333333333333", + }, + }, + }) +} + +func TestUnitSolutionGitBranchResource_Validate_ScopeMismatch(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001\?%24expand=permissions%2Cproperties\.capacity%2Cproperties%2FbillingPolicy(%2Cproperties%2FcopilotPolicies)?&api-version=2023-06-01$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/shared/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions\?.*solutionid\+eq\+33333333-3333-3333-3333-333333333333.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"solutionid":"33333333-3333-3333-3333-333333333333","uniquename":"sample_solution","friendlyname":"Sample Solution","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":false,"version":"1.0.0.0"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/organizations(\?.*)?$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationid":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","orgdborgsettings":"EnvironmentScope"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolconfigurations%2811111111-1111-1111-1111-111111111111%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json").String()), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "powerplatform_solution_git_branch" "sample" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_integration_id = "11111111-1111-1111-1111-111111111111" + solution_id = "00000000-0000-0000-0000-000000000001_33333333-3333-3333-3333-333333333333" + branch_name = "main" + root_folder_path = "solutions/sample-solution" +} +`, + ExpectError: regexp.MustCompile(`Invalid git integration scope for solution binding`), + }, + }, + }) +} + +func TestUnitSolutionGitBranchResource_Validate_DuplicateBinding(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + mocks.ActivateEnvironmentHttpMocks() + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://api\.bap\.microsoft\.com/providers/Microsoft\.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001\?%24expand=permissions%2Cproperties\.capacity%2Cproperties%2FbillingPolicy(%2Cproperties%2FcopilotPolicies)?&api-version=2023-06-01$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/shared/get_environment_00000000-0000-0000-0000-000000000001.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.2/solutions\?.*solutionid\+eq\+33333333-3333-3333-3333-333333333333.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"solutionid":"33333333-3333-3333-3333-333333333333","uniquename":"sample_solution","friendlyname":"Sample Solution","ismanaged":false,"isvisible":true,"enabledforsourcecontrolintegration":false,"version":"1.0.0.0"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/organizations(\?.*)?$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationid":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","orgdborgsettings":"SolutionScope"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolconfigurations%2811111111-1111-1111-1111-111111111111%29$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, httpmock.File("tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json").String()), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/gitbranches\?%24filter=.*$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"organizationname":"example-org","projectname":"example-project","repositoryname":"example-repo","branchname":"main"}]}`), nil + }) + + httpmock.RegisterRegexpResponder("GET", regexp.MustCompile(`^https://00000000-0000-0000-0000-000000000001\.crm4\.dynamics\.com/api/data/v9\.0/sourcecontrolbranchconfigurations\?partitionId=33333333-3333-3333-3333-333333333333$`), + func(req *http.Request) (*http.Response, error) { + return httpmock.NewStringResponse(http.StatusOK, `{"value":[{"sourcecontrolbranchconfigurationid":"22222222-2222-2222-2222-222222222222","name":"33333333-3333-3333-3333-333333333333","partitionid":"33333333-3333-3333-3333-333333333333","branchname":"main","upstreambranchname":"main","rootfolderpath":"solutions/sample-solution","_sourcecontrolconfigurationid_value":"11111111-1111-1111-1111-111111111111"}]}`), nil + }) + + resource.Test(t, resource.TestCase{ + IsUnitTest: true, + ProtoV6ProviderFactories: mocks.TestUnitTestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +resource "powerplatform_solution_git_branch" "sample" { + environment_id = "00000000-0000-0000-0000-000000000001" + git_integration_id = "11111111-1111-1111-1111-111111111111" + solution_id = "00000000-0000-0000-0000-000000000001_33333333-3333-3333-3333-333333333333" + branch_name = "main" + root_folder_path = "solutions/sample-solution" +} +`, + ExpectError: regexp.MustCompile(`Duplicate solution git branch binding`), + }, + }, + }) +} diff --git a/internal/services/git_integration/tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json b/internal/services/git_integration/tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json new file mode 100644 index 000000000..9377d3684 --- /dev/null +++ b/internal/services/git_integration/tests/resource/environment_git_integration/get_sourcecontrolconfiguration_1.json @@ -0,0 +1,8 @@ +{ + "sourcecontrolconfigurationid": "11111111-1111-1111-1111-111111111111", + "name": "example-repo", + "organizationname": "example-org", + "projectname": "example-project", + "repositoryname": "example-repo", + "gitprovider": 0 +} diff --git a/internal/services/git_integration/tests/resource/solution_git_branch/get_sourcecontrolbranchconfiguration_1.json b/internal/services/git_integration/tests/resource/solution_git_branch/get_sourcecontrolbranchconfiguration_1.json new file mode 100644 index 000000000..919ef880d --- /dev/null +++ b/internal/services/git_integration/tests/resource/solution_git_branch/get_sourcecontrolbranchconfiguration_1.json @@ -0,0 +1,9 @@ +{ + "sourcecontrolbranchconfigurationid": "22222222-2222-2222-2222-222222222222", + "name": "33333333-3333-3333-3333-333333333333", + "partitionid": "33333333-3333-3333-3333-333333333333", + "branchname": "main", + "upstreambranchname": "main", + "rootfolderpath": "solutions/sample-solution", + "_sourcecontrolconfigurationid_value": "11111111-1111-1111-1111-111111111111" +} diff --git a/internal/services/git_integration/tests/resource/solution_git_branch/get_sourcecontrolbranchconfiguration_2.json b/internal/services/git_integration/tests/resource/solution_git_branch/get_sourcecontrolbranchconfiguration_2.json new file mode 100644 index 000000000..64392e4a0 --- /dev/null +++ b/internal/services/git_integration/tests/resource/solution_git_branch/get_sourcecontrolbranchconfiguration_2.json @@ -0,0 +1,9 @@ +{ + "sourcecontrolbranchconfigurationid": "22222222-2222-2222-2222-222222222222", + "name": "33333333-3333-3333-3333-333333333333", + "partitionid": "33333333-3333-3333-3333-333333333333", + "branchname": "develop", + "upstreambranchname": "main", + "rootfolderpath": "solutions/sample-solution-updated", + "_sourcecontrolconfigurationid_value": "11111111-1111-1111-1111-111111111111" +} diff --git a/internal/services/git_integration/tests/shared/get_environment_00000000-0000-0000-0000-000000000001.json b/internal/services/git_integration/tests/shared/get_environment_00000000-0000-0000-0000-000000000001.json new file mode 100644 index 000000000..3ee0c5a82 --- /dev/null +++ b/internal/services/git_integration/tests/shared/get_environment_00000000-0000-0000-0000-000000000001.json @@ -0,0 +1,13 @@ +{ + "id": "/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments/00000000-0000-0000-0000-000000000001", + "name": "00000000-0000-0000-0000-000000000001", + "type": "Microsoft.BusinessAppPlatform/scopes/environments", + "location": "unitedstates", + "properties": { + "displayName": "Git Integration Test Environment", + "environmentSku": "Sandbox", + "linkedEnvironmentMetadata": { + "instanceUrl": "https://00000000-0000-0000-0000-000000000001.crm4.dynamics.com" + } + } +} diff --git a/internal/services/git_integration/validation.go b/internal/services/git_integration/validation.go new file mode 100644 index 000000000..27c246b95 --- /dev/null +++ b/internal/services/git_integration/validation.go @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/microsoft/terraform-provider-power-platform/internal/customerrors" +) + +func (r *EnvironmentGitIntegrationResource) validateRemoteConfiguration(ctx context.Context, data EnvironmentGitIntegrationResourceModel, diags *diag.Diagnostics) { + organizations, err := r.GitIntegrationClient.ListGitOrganizations(ctx, data.EnvironmentID.ValueString()) + if err != nil { + diags.AddError("Client error when validating git organization", err.Error()) + return + } + + if !containsOrganization(organizations, data.OrganizationName.ValueString()) { + diags.AddAttributeError( + path.Root("organization_name"), + "Invalid organization_name", + fmt.Sprintf("The Git organization `%s` was not returned by the Dataverse `gitorganizations` endpoint for environment `%s`.", data.OrganizationName.ValueString(), data.EnvironmentID.ValueString()), + ) + return + } + + projects, err := r.GitIntegrationClient.ListGitProjects(ctx, data.EnvironmentID.ValueString(), data.OrganizationName.ValueString()) + if err != nil { + diags.AddError("Client error when validating git project", err.Error()) + return + } + + if !containsProject(projects, data.ProjectName.ValueString()) { + diags.AddAttributeError( + path.Root("project_name"), + "Invalid project_name", + fmt.Sprintf("The Git project `%s` was not returned by the Dataverse `gitprojects` endpoint for organization `%s`.", data.ProjectName.ValueString(), data.OrganizationName.ValueString()), + ) + return + } + + repositories, err := r.GitIntegrationClient.ListGitRepositories(ctx, data.EnvironmentID.ValueString(), data.OrganizationName.ValueString(), data.ProjectName.ValueString()) + if err != nil { + diags.AddError("Client error when validating git repository", err.Error()) + return + } + + if !containsRepository(repositories, data.RepositoryName.ValueString()) { + diags.AddAttributeError( + path.Root("repository_name"), + "Invalid repository_name", + fmt.Sprintf("The Git repository `%s` was not returned by the Dataverse `gitrepositories` endpoint for the configured organization and project.", data.RepositoryName.ValueString()), + ) + } +} + +func (r *SolutionGitBranchResource) validateRemoteConfiguration(ctx context.Context, data SolutionGitBranchResourceModel, currentBranchID string, diags *diag.Diagnostics) string { + solutionID, err := normalizeSolutionID(data.EnvironmentID.ValueString(), data.SolutionID.ValueString()) + if err != nil { + diags.AddAttributeError( + path.Root("solution_id"), + "Invalid solution_id", + err.Error(), + ) + return "" + } + + configuration, err := r.GitIntegrationClient.GetEnvironmentGitIntegration(ctx, data.EnvironmentID.ValueString(), data.GitIntegrationID.ValueString()) + if err != nil { + if errors.Is(err, customerrors.ErrObjectNotFound) { + diags.AddAttributeError( + path.Root("git_integration_id"), + "Unknown git_integration_id", + fmt.Sprintf("The Git integration `%s` was not found in environment `%s`.", data.GitIntegrationID.ValueString(), data.EnvironmentID.ValueString()), + ) + return "" + } + + diags.AddError("Client error when validating git integration", err.Error()) + return "" + } + + if _, err := r.GitIntegrationClient.GetUnmanagedSolutionByID(ctx, data.EnvironmentID.ValueString(), solutionID); err != nil { + if errors.Is(err, customerrors.ErrObjectNotFound) { + diags.AddAttributeError( + path.Root("solution_id"), + "Unknown solution_id", + fmt.Sprintf("The solution `%s` was not found as an unmanaged solution in environment `%s`.", data.SolutionID.ValueString(), data.EnvironmentID.ValueString()), + ) + return "" + } + + diags.AddError("Client error when validating solution", err.Error()) + return "" + } + + scope, err := r.GitIntegrationClient.GetSourceControlIntegrationScope(ctx, data.EnvironmentID.ValueString()) + if err != nil { + diags.AddError("Client error when validating source control integration scope", err.Error()) + return "" + } + + if scope != scopeSolution { + diags.AddAttributeError( + path.Root("git_integration_id"), + "Invalid git integration scope for solution binding", + fmt.Sprintf("The parent Git integration `%s` uses scope `%s`. `powerplatform_solution_git_branch` requires the parent `powerplatform_environment_git_integration.scope` to be `Solution`.", data.GitIntegrationID.ValueString(), scope), + ) + return "" + } + + branches, err := r.GitIntegrationClient.ListGitBranches(ctx, data.EnvironmentID.ValueString(), configuration.OrganizationName, configuration.ProjectName, configuration.RepositoryName) + if err != nil { + diags.AddError("Client error when validating git branch", err.Error()) + return "" + } + + if !containsBranch(branches, data.BranchName.ValueString()) { + diags.AddAttributeError( + path.Root("branch_name"), + "Invalid branch_name", + fmt.Sprintf("The branch `%s` was not returned by the Dataverse `gitbranches` endpoint for repository `%s`.", data.BranchName.ValueString(), configuration.RepositoryName), + ) + } + + if !data.UpstreamBranchName.IsNull() && !data.UpstreamBranchName.IsUnknown() && data.UpstreamBranchName.ValueString() != "" && !containsBranch(branches, data.UpstreamBranchName.ValueString()) { + diags.AddAttributeError( + path.Root("upstream_branch_name"), + "Invalid upstream_branch_name", + fmt.Sprintf("The upstream branch `%s` was not returned by the Dataverse `gitbranches` endpoint for repository `%s`.", data.UpstreamBranchName.ValueString(), configuration.RepositoryName), + ) + } + + existingBinding, err := r.GitIntegrationClient.FindSolutionGitBranchByPartition(ctx, data.EnvironmentID.ValueString(), data.GitIntegrationID.ValueString(), solutionID) + if err != nil && !errors.Is(err, customerrors.ErrObjectNotFound) { + diags.AddError("Client error when validating existing solution git branch", err.Error()) + return "" + } + if err == nil && existingBinding != nil && existingBinding.ID != currentBranchID { + diags.AddAttributeError( + path.Root("solution_id"), + "Duplicate solution git branch binding", + fmt.Sprintf("A Git branch binding already exists for solution `%s` under Git integration `%s`. Only one `powerplatform_solution_git_branch` is allowed per solution within the same environment Git integration.", data.SolutionID.ValueString(), data.GitIntegrationID.ValueString()), + ) + } + + return solutionID +} + +func normalizeSolutionID(environmentID, configuredValue string) (string, error) { + if configuredValue == "" { + return "", errors.New("the `solution_id` attribute must not be empty") + } + + environmentPrefix, solutionID, found := strings.Cut(configuredValue, "_") + if !found { + return "", fmt.Errorf("the `solution_id` value `%s` must be the `id` exported by `powerplatform_solution` for environment `%s`", configuredValue, environmentID) + } + + parsedEnvironmentPrefix, err := uuid.Parse(environmentPrefix) + if err != nil { + return "", fmt.Errorf("the `solution_id` value `%s` must use a valid environment GUID prefix", configuredValue) + } + + if _, err := uuid.Parse(solutionID); err != nil { + return "", fmt.Errorf("the `solution_id` value `%s` must use a valid Dataverse solution GUID suffix", configuredValue) + } + + parsedEnvironmentID, err := uuid.Parse(environmentID) + if err != nil { + return "", fmt.Errorf("the `environment_id` value `%s` must be a valid environment GUID", environmentID) + } + + if parsedEnvironmentPrefix.String() != parsedEnvironmentID.String() { + return "", fmt.Errorf("the `solution_id` value `%s` uses environment `%s`, but this resource is targeting environment `%s`", configuredValue, environmentPrefix, environmentID) + } + + return solutionID, nil +} + +func buildSolutionReference(environmentID, solutionID string) string { + return fmt.Sprintf("%s_%s", environmentID, solutionID) +} + +func containsOrganization(values []gitOrganizationDto, organizationName string) bool { + for _, item := range values { + if strings.EqualFold(item.OrganizationName, organizationName) { + return true + } + } + + return false +} + +func containsProject(values []gitProjectDto, projectName string) bool { + for _, item := range values { + if strings.EqualFold(item.ProjectName, projectName) { + return true + } + } + + return false +} + +func containsRepository(values []gitRepositoryDto, repositoryName string) bool { + for _, item := range values { + if strings.EqualFold(item.RepositoryName, repositoryName) { + return true + } + } + + return false +} + +func containsBranch(values []gitBranchDto, branchName string) bool { + for _, item := range values { + if strings.EqualFold(item.BranchName, branchName) { + return true + } + } + + return false +} diff --git a/internal/services/git_integration/validation_internal_test.go b/internal/services/git_integration/validation_internal_test.go new file mode 100644 index 000000000..5a1b0a7bf --- /dev/null +++ b/internal/services/git_integration/validation_internal_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package git_integration + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnitNormalizeSolutionID_RejectsInvalidGUIDSegments(t *testing.T) { + _, err := normalizeSolutionID("00000000-0000-0000-0000-000000000001", "not-a-guid_33333333-3333-3333-3333-333333333333") + require.ErrorContains(t, err, "valid environment GUID prefix") + + _, err = normalizeSolutionID("00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000001_not-a-guid") + require.ErrorContains(t, err, "valid Dataverse solution GUID suffix") +} + +func TestUnitNormalizeSolutionID_AllowsCaseInsensitiveEnvironmentMatch(t *testing.T) { + solutionID, err := normalizeSolutionID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA_33333333-3333-3333-3333-333333333333") + require.NoError(t, err) + require.Equal(t, "33333333-3333-3333-3333-333333333333", solutionID) + + solutionID, err = normalizeSolutionID("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa_33333333-3333-3333-3333-333333333333") + require.NoError(t, err) + require.Equal(t, "33333333-3333-3333-3333-333333333333", solutionID) +}