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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<#
.SYNOPSIS
Migrates an environment from the AppSource Volunteer Management managed solution to the
open-source Volunteer Management (OS) build that ships under a separate identity.

.DESCRIPTION
Both solutions are published by Microsoft; this script distinguishes them by origin:
the AppSource managed solution (installed from the marketplace) versus the open-source
build (compiled from the public GitHub repository).

The open-source build installs side-by-side with its own identity
(unique name 'volunteermanagementos', plugin assembly 'PluginsOS'). It brings its
own copies of the four PCF controls. The AppSource managed solution cannot simply be
deleted, because the forms and views it contributed reference its PCF controls in the
*active* (merged) layer. That Form -> Control "Published" dependency blocks deletion.

This script removes that blocker by rewriting the active form/view layers so they no
longer reference the AppSource PCF controls (the default control is substituted), then
publishes. After that the AppSource managed solution can be deleted. Finally the OS
solution can be (re)imported / upgraded so its own forms and controls take over.

Stages (each is opt-in via a switch; nothing destructive runs unless requested):
-StripReferences Rewrite forms + saved queries to drop AppSource PCF refs, then PublishAllXml.
-DeleteAppSourceSolution Delete the AppSource managed solution by unique name.
-Verify Report assembly / PCF ownership / SDK step registration.

Run order for a full migration:
1. Import the OS managed solution (volunteermanagementos) side-by-side (pac solution import).
2. .\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl <url> -StripReferences
3. .\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl <url> -DeleteAppSourceSolution
4. Re-import / upgrade the OS solution so its forms restore the PCF controls (OS-owned).
5. .\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl <url> -Verify

.PARAMETER EnvironmentUrl
The Dataverse environment URL, e.g. https://contoso.crm.dynamics.com

.PARAMETER StripReferences
Rewrite the active form/view layers to remove the AppSource PCF control references and publish.

.PARAMETER DeleteAppSourceSolution
Delete the AppSource managed solution identified by -AppSourceSolutionUniqueName.

.PARAMETER Verify
Print a verification report (plugin assemblies, PCF control ownership, SDK step counts).

.PARAMETER AppSourceSolutionUniqueName
Unique name of the AppSource managed solution to delete. Default: VolunteerManagement.

.PARAMETER ControlNames
The PCF control schema names whose references should be stripped from forms/views.
Defaults to the four controls shipped by Volunteer Management.

.PARAMETER AccessToken
A pre-acquired Dataverse bearer token. If omitted, the script acquires one with the
Azure CLI (see -AzCommand).

.PARAMETER AzCommand
The Azure CLI executable used to acquire a token when -AccessToken is not supplied.
Default: 'az'. You may point this at an isolated CLI wrapper for non-default sign-ins.

.PARAMETER WhatIf
Supported on the destructive stages (PATCH / publish / delete) via SupportsShouldProcess.

.EXAMPLE
.\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl https://contoso.crm.dynamics.com -StripReferences

.EXAMPLE
.\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl https://contoso.crm.dynamics.com -DeleteAppSourceSolution

.NOTES
Take a backup of the environment before running the destructive stages. The strip stage
edits active form/view layers in place; deleting the AppSource solution is irreversible
without that backup.
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory = $true)]
[string]$EnvironmentUrl,

[switch]$StripReferences,
[switch]$DeleteAppSourceSolution,
[switch]$Verify,

[string]$AppSourceSolutionUniqueName = 'VolunteerManagement',

[string[]]$ControlNames = @(
'msnfp_microsoftdynamics365nonprofitaccelerator.EngagementOpportunitySummary',
'msnfp_microsoftdynamics365nonprofitaccelerator.OnboardingStages',
'msnfp_microsoftdynamics365nonprofitaccelerator.SendMessages',
'msnfp_VolunteerManagement.GetStarted'
),

[string]$AccessToken,
[string]$AzCommand = 'az'
)

$ErrorActionPreference = 'Stop'
$EnvironmentUrl = $EnvironmentUrl.TrimEnd('/')
$base = "$EnvironmentUrl/api/data/v9.2"

if (-not ($StripReferences -or $DeleteAppSourceSolution -or $Verify)) {
Write-Host 'Nothing to do. Specify at least one stage: -StripReferences, -DeleteAppSourceSolution, or -Verify.' -ForegroundColor Yellow
Write-Host 'Run "Get-Help .\Migrate-VolunteerManagementToOpenSource.ps1 -Full" for details.'
return
}

function Get-DataverseToken {
param([string]$Resource, [string]$Token, [string]$Cli)
if ($Token) { return $Token }
$t = & $Cli account get-access-token --resource $Resource --query accessToken --output tsv 2>$null
if (-not $t) {
throw "Could not acquire an access token for $Resource via '$Cli'. Sign in (az login) or pass -AccessToken."
}
return $t.Trim()
}

$token = Get-DataverseToken -Resource $EnvironmentUrl -Token $AccessToken -Cli $AzCommand
$headers = @{
Authorization = "Bearer $token"
Accept = 'application/json'
'Content-Type' = 'application/json'
'OData-MaxVersion' = '4.0'
'OData-Version' = '4.0'
}

# Retrieve every page of a Dataverse Web API query. The Web API caps each response at a
# page (default 5000 rows) and returns @odata.nextLink for the rest, so a single call can
# miss forms/views that still reference the AppSource PCF controls.
function Get-AllPages {
param([string]$Url)
$all = @()
$next = $Url
while ($next) {
$page = Invoke-RestMethod -Uri $next -Headers $headers
if ($page.value) { $all += $page.value }
$next = $page.'@odata.nextLink'
}
return $all
}

# Replace any <customControl name="<target>" .../> with a clone of the form/view's default
# control definition, preserving formFactor. This removes the Form/View -> PCF dependency.
function Repair-ControlXml {
param([string]$XmlText, [string[]]$TargetNames)
$doc = New-Object System.Xml.XmlDocument
$doc.PreserveWhitespace = $true
$doc.LoadXml($XmlText)
$changed = 0
foreach ($cd in $doc.SelectNodes('//controlDescription')) {
$pcf = @($cd.SelectNodes('customControl') | Where-Object { $TargetNames -contains $_.GetAttribute('name') })
if ($pcf.Count -eq 0) { continue }
$def = @($cd.SelectNodes('customControl') | Where-Object { $_.GetAttribute('id') -and -not $_.GetAttribute('name') })[0]
if (-not $def) { continue }
foreach ($p in $pcf) {
$formFactor = $p.GetAttribute('formFactor')
$clone = $def.CloneNode($true)
if ($formFactor) { $clone.SetAttribute('formFactor', $formFactor) }
$cd.ReplaceChild($clone, $p) | Out-Null
$changed++
}
}
return [pscustomobject]@{ Xml = $doc.OuterXml; Changed = $changed }
}

function Invoke-StripReferences {
Write-Host '=== Stripping AppSource PCF references from system forms ===' -ForegroundColor Cyan
$forms = Get-AllPages -Url "$base/systemforms?`$select=formid,name,objecttypecode,formxml"
$patchedForms = 0
foreach ($f in $forms) {
if (-not $f.formxml) { continue }
if (-not ($ControlNames | Where-Object { $f.formxml.Contains($_) })) { continue }
$res = Repair-ControlXml -XmlText $f.formxml -TargetNames $ControlNames
if ($res.Changed -gt 0 -and $PSCmdlet.ShouldProcess("form '$($f.name)' ($($f.objecttypecode))", "replace $($res.Changed) PCF control reference(s)")) {
$body = @{ formxml = $res.Xml } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "$base/systemforms($($f.formid))" -Method Patch -Headers $headers -Body $body | Out-Null
Write-Host (" FORM {0} ({1}): replaced {2}" -f $f.name, $f.objecttypecode, $res.Changed)
$patchedForms++
}
}
Write-Host "Patched forms: $patchedForms"

Write-Host '=== Stripping AppSource PCF references from saved queries (views) ===' -ForegroundColor Cyan
$views = Get-AllPages -Url "$base/savedqueries?`$select=savedqueryid,name,returnedtypecode,layoutxml"
$patchedViews = 0
foreach ($v in $views) {
if (-not $v.layoutxml) { continue }
if (-not ($ControlNames | Where-Object { $v.layoutxml.Contains($_) })) { continue }
$res = Repair-ControlXml -XmlText $v.layoutxml -TargetNames $ControlNames
if ($res.Changed -gt 0 -and $PSCmdlet.ShouldProcess("saved query '$($v.name)' ($($v.returnedtypecode))", "replace $($res.Changed) PCF control reference(s)")) {
$body = @{ layoutxml = $res.Xml } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "$base/savedqueries($($v.savedqueryid))" -Method Patch -Headers $headers -Body $body | Out-Null
Write-Host (" VIEW {0} ({1}): replaced {2}" -f $v.name, $v.returnedtypecode, $res.Changed)
$patchedViews++
}
}
Write-Host "Patched saved queries: $patchedViews"

if (($patchedForms + $patchedViews) -gt 0) {
if ($PSCmdlet.ShouldProcess($EnvironmentUrl, 'PublishAllXml')) {
Write-Host '=== PublishAllXml ===' -ForegroundColor Cyan
Invoke-RestMethod -Uri "$base/PublishAllXml" -Method Post -Headers $headers -Body '{}' | Out-Null
Write-Host 'Publish complete.'
}
}
else {
Write-Host 'No forms or views referenced the target controls; nothing to publish.'
}
}

function Invoke-DeleteAppSourceSolution {
Write-Host "=== Deleting AppSource managed solution '$AppSourceSolutionUniqueName' ===" -ForegroundColor Cyan
$sol = (Invoke-RestMethod -Uri "$base/solutions?`$select=solutionid,friendlyname,version,ismanaged&`$filter=uniquename eq '$AppSourceSolutionUniqueName'" -Headers $headers).value
if (-not $sol) {
Write-Host " Solution '$AppSourceSolutionUniqueName' not found (already removed?)." -ForegroundColor Yellow
return
}
$s = $sol[0]
Write-Host (" Found: {0} v{1} (managed={2})" -f $s.friendlyname, $s.version, $s.ismanaged)
if ($PSCmdlet.ShouldProcess("solution '$AppSourceSolutionUniqueName' ($($s.friendlyname) v$($s.version))", 'DELETE')) {
Invoke-RestMethod -Uri "$base/solutions($($s.solutionid))" -Method Delete -Headers $headers | Out-Null
Write-Host ' Deleted.'
}
}

function Invoke-VerifyReport {
Write-Host '=== Plugin assemblies (Plugins*) ===' -ForegroundColor Cyan
(Invoke-RestMethod -Uri "$base/pluginassemblies?`$select=name,publickeytoken&`$filter=startswith(name,'Plugins')" -Headers $headers).value |
Format-Table name, publickeytoken -AutoSize | Out-Host

Write-Host '=== PCF controls and owning solutions ===' -ForegroundColor Cyan
foreach ($n in $ControlNames) {
$cc = (Invoke-RestMethod -Uri "$base/customcontrols?`$select=customcontrolid,name&`$filter=name eq '$n'" -Headers $headers).value
if ($cc) {
$sc = (Invoke-RestMethod -Uri "$base/solutioncomponents?`$select=solutioncomponentid&`$filter=objectid eq $($cc[0].customcontrolid) and componenttype eq 66&`$expand=solutionid(`$select=uniquename)" -Headers $headers).value
Write-Host (" {0,-34} owners=[{1}]" -f $n.Split('.')[-1], (($sc | ForEach-Object { $_.solutionid.uniquename }) -join ', '))
}
else {
Write-Host (" {0,-34} MISSING" -f $n.Split('.')[-1]) -ForegroundColor Yellow
}
}

Write-Host '=== SDK steps registered on PluginsOS ===' -ForegroundColor Cyan
$asm = (Invoke-RestMethod -Uri "$base/pluginassemblies?`$select=pluginassemblyid,name&`$filter=name eq 'PluginsOS'" -Headers $headers).value
if ($asm) {
$types = (Invoke-RestMethod -Uri "$base/plugintypes?`$select=plugintypeid&`$filter=_pluginassemblyid_value eq $($asm[0].pluginassemblyid)" -Headers $headers).value
$stepCount = 0
foreach ($t in $types) {
$steps = (Invoke-RestMethod -Uri "$base/sdkmessageprocessingsteps?`$select=sdkmessageprocessingstepid&`$filter=_plugintypeid_value eq $($t.plugintypeid)" -Headers $headers).value
$stepCount += $steps.Count
}
Write-Host (" PluginsOS: {0} plugin type(s), {1} SDK step(s) registered" -f $types.Count, $stepCount)
}
else {
Write-Host ' PluginsOS assembly not found.' -ForegroundColor Yellow
}
}

if ($StripReferences) { Invoke-StripReferences }
if ($DeleteAppSourceSolution) { Invoke-DeleteAppSourceSolution }
if ($Verify) { Invoke-VerifyReport }

Write-Host 'Done.' -ForegroundColor Green
93 changes: 93 additions & 0 deletions VolunteerManagement/Deployment/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Migrating Volunteer Management to the open-source build

Both the AppSource and the open-source (OS) Volunteer Management solutions are published by
Microsoft; they are distinguished by **origin** — the AppSource managed solution (installed
from the marketplace) versus the OS build (compiled from the public GitHub repository).

The OS build installs **side-by-side** with its own identity so it never collides with the
AppSource solution:

| Aspect | AppSource (marketplace) | Open-source build |
| --- | --- | --- |
| Solution unique name | `VolunteerManagement` | `volunteermanagementos` |
| Plugin assembly | `Plugins` (token `8ad1edaac4bc000c`) | `PluginsOS` (token `703f25cc8a15a472`) |
| PCF controls | shipped & owned | shipped & owned (own copies) |

Because the two solutions have **separate identities**, the OS build can be imported while
the AppSource one is still present. The OS build carries its own copies of the four PCF
controls, its own plugin assembly, and its own SDK steps.

## Why a strip step is required

The AppSource managed solution contributes forms and views whose **active (merged) layer**
references its PCF controls. That `Form -> Control` dependency is *Published* and lives in
the active layer regardless of solution ownership, so it blocks deletion of the AppSource
solution. Granting the OS solution ownership of those components (e.g. `AddSolutionComponent`)
does **not** clear the dependency — this was verified empirically.

The `Migrate-VolunteerManagementToOpenSource.ps1` script removes the blocker by rewriting the
active form/view layers so they substitute the default control for the AppSource PCF control,
then publishing. After that the AppSource solution deletes cleanly.

## Migration steps

> Run all commands from the **repository root**. Paths below are relative to it: the solution
> project lives under `VolunteerManagement\VolunteerManagement\` and the migration script under
> `VolunteerManagement\Deployment\`.

1. **Back up the environment.** The strip stage edits active form/view layers in place and
deleting the AppSource solution is irreversible without a backup.

2. **Import the OS managed solution side-by-side.**

```powershell
pac solution import --path .\VolunteerManagement\VolunteerManagement\bin\Release\VolunteerManagement_managed.zip
```

(Build it first with `dotnet build VolunteerManagement\VolunteerManagement\VolunteerManagement.cdsproj -c Release`.)

3. **Strip the AppSource PCF references and publish.**

```powershell
.\VolunteerManagement\Deployment\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl https://contoso.crm.dynamics.com -StripReferences
```

4. **Delete the AppSource managed solution.**

```powershell
.\VolunteerManagement\Deployment\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl https://contoso.crm.dynamics.com -DeleteAppSourceSolution
```

5. **Re-import / upgrade the OS solution** so its own forms restore the PCF controls under OS
ownership.

```powershell
pac solution import --path .\VolunteerManagement\VolunteerManagement\bin\Release\VolunteerManagement_managed.zip --force-overwrite
```
Comment thread
Copilot marked this conversation as resolved.

6. **Verify.**

```powershell
.\VolunteerManagement\Deployment\Migrate-VolunteerManagementToOpenSource.ps1 -EnvironmentUrl https://contoso.crm.dynamics.com -Verify
```

## Authentication

By default the script acquires a Dataverse token with the Azure CLI (`az account get-access-token`).
Sign in first with `az login`, or:

- pass a pre-acquired bearer token with `-AccessToken <token>`, or
- point `-AzCommand` at an alternate/isolated Azure CLI executable for a non-default sign-in.

## Safety

- Every stage is **opt-in**; running the script with no stage switch does nothing.
- The destructive stages support `-WhatIf` and `-Confirm` (the script declares
`ConfirmImpact = 'High'`), so you can preview each PATCH / publish / delete.
- Always take an environment backup before steps 3–5.

## Parameters

Run `Get-Help .\Migrate-VolunteerManagementToOpenSource.ps1 -Full` for the complete reference.
The control list and the AppSource solution unique name are parameterized
(`-ControlNames`, `-AppSourceSolutionUniqueName`) and default to the Volunteer Management values.
Loading