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
24 changes: 23 additions & 1 deletion .build/DscConfigurationTasks.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ task CleanModuleFolder {

Wait-DscLocalConfigurationManager

dir -Path $programFileModulePath |
Get-ChildItem -Path $programFileModulePath |
Where-Object { $_.BaseName -notin $modulesToKeep } |
Remove-Item -Recurse -Force

Expand Down Expand Up @@ -105,3 +105,25 @@ task InitializeModuleFolder {
}

}

task InitializeModuleFolderForDeltaReport {

Wait-DscLocalConfigurationManager

$programFileModulePath = 'C:\Program Files\WindowsPowerShell\Modules'

Write-Host "Copying modules from '$requiredModulesPath' to '$programFileModulePath'"
Get-ChildItem -Path $requiredModulesPath | ForEach-Object {
$module = $_
Write-Host "Copying module '$($module.BaseName)'"
try
{
$module | Copy-Item -Destination $programFileModulePath -Recurse -Force -ErrorAction Stop
}
catch
{
throw "Failed to copy module '$($module.BaseName)' to '$programFileModulePath': $_"
}
}

}
129 changes: 129 additions & 0 deletions .build/Export/DeltaReport.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
task NewM365DscDeltaReport {

$sourceTenant = if ($env:DeltaReportSourceTenant)
{
$env:DeltaReportSourceTenant
}
else
{
Write-Error "The environment variable 'DeltaReportSourceTenant' is not set. Please specify the source tenant for the delta report." -ErrorAction Stop
}

$inputDirectory = if ($env:DeltaReportInputDirectory)
{
$env:DeltaReportInputDirectory
}
else
{
Join-Path -Path $OutputDirectory -ChildPath 'Export'
}

$reportDirectory = if ($env:DeltaReportOutputDirectory)
{
$env:DeltaReportOutputDirectory
}
else
{
Join-Path -Path $OutputDirectory -ChildPath 'DeltaReport'
}

if (-not (Test-Path -Path $inputDirectory))
{
Write-Error "The input directory '$inputDirectory' does not exist. Please download the 'TenantConfig-*' artifacts first." -ErrorAction Stop
}

Write-Host "Looking for exported tenant configurations in '$inputDirectory'" -ForegroundColor Yellow

#The export creates '<Tenant>\<DscResource>\M365TenantConfig.ps1', hence the tenant is the grandparent of each configuration file.
$tenants = Get-ChildItem -Path $inputDirectory -Filter *.ps1 -File -Recurse |
Where-Object { $null -ne $_.Directory.Parent } |
Group-Object -Property { $_.Directory.Parent.FullName }

if ($tenants.Count -eq 0)
{
Write-Error "Could not find any exported DSC configuration (*.ps1) in '$inputDirectory'." -ErrorAction Stop
}

Write-Host "Found $($tenants.Count) tenant(s) in the input directory." -ForegroundColor Yellow

$stagingDirectory = Join-Path -Path $reportDirectory -ChildPath '_staging'
if (Test-Path -Path $reportDirectory)
{
Remove-Item -Path $reportDirectory -Recurse -Force
}
New-Item -Path $stagingDirectory -ItemType Directory -Force | Out-Null

$mergedConfigurations = @{}

foreach ($tenant in $tenants)
{
$tenantName = Split-Path -Path $tenant.Name -Leaf
Write-Host "Merging $($tenant.Count) configuration file(s) of tenant '$tenantName'" -ForegroundColor Yellow

$tenantStagingDirectory = Join-Path -Path $stagingDirectory -ChildPath $tenantName
New-Item -Path $tenantStagingDirectory -ItemType Directory -Force | Out-Null

#Join-M365DSCConfiguration merges all configurations of one folder into the base file, so the nested export structure has to be flattened first.
$baseConfigurationFile = 'M365TenantConfig.ps1'
$isBaseConfiguration = $true
foreach ($configurationFile in ($tenant.Group | Sort-Object -Property FullName))
{
$targetName = if ($isBaseConfiguration)
{
$baseConfigurationFile
}
else
{
"$($configurationFile.Directory.Name).ps1"
}
$isBaseConfiguration = $false

Copy-Item -Path $configurationFile.FullName -Destination (Join-Path -Path $tenantStagingDirectory -ChildPath $targetName) -Force
}

#Join-M365DSCConfiguration returns the merged configuration as a string instead of writing it to disk.
$mergedConfiguration = Join-M365DSCConfiguration -ConfigurationFile $baseConfigurationFile -ConfigurationPath $tenantStagingDirectory

if ([System.String]::IsNullOrWhiteSpace($mergedConfiguration))
{
Write-Error "Join-M365DSCConfiguration returned an empty configuration for tenant '$tenantName'." -ErrorAction Stop
}

$tenantConfigurationPath = Join-Path -Path $reportDirectory -ChildPath "$tenantName.ps1"
Set-Content -Path $tenantConfigurationPath -Value $mergedConfiguration -Encoding utf8 -Force
$mergedConfigurations.Add($tenantName, $tenantConfigurationPath)

Write-Host " Merged configuration written to '$tenantConfigurationPath'." -ForegroundColor Green
}

Remove-Item -Path $stagingDirectory -Recurse -Force

if (-not $mergedConfigurations.ContainsKey($sourceTenant))
{
Write-Error "The source tenant '$sourceTenant' was not found in '$inputDirectory'. Available tenants: $($mergedConfigurations.Keys -join ', ')." -ErrorAction Stop
}

$destinationTenants = $mergedConfigurations.Keys | Where-Object { $_ -ne $sourceTenant } | Sort-Object
if (-not $destinationTenants)
{
Write-Error "There is no tenant to compare the source tenant '$sourceTenant' with." -ErrorAction Stop
}

foreach ($destinationTenant in $destinationTenants)
{
$reportPath = Join-Path -Path $reportDirectory -ChildPath "DeltaReport-$sourceTenant-vs-$destinationTenant.html"
Write-Host "Creating delta report of '$sourceTenant' against '$destinationTenant'" -ForegroundColor Yellow

$Parameters = @{
Source = $mergedConfigurations[$sourceTenant]
Destination = $mergedConfigurations[$destinationTenant]
OutputPath = $reportPath
Type = 'HTML'
DriftOnly = $true
}
New-M365DSCDeltaReport @Parameters
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Write-Host " Delta report written to '$reportPath'." -ForegroundColor Green
}

}
33 changes: 31 additions & 2 deletions .memory-bank/activeContext.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-08-17
last-verified: 2026-09-06
owner: active-agent
source: current task evidence
---
Expand All @@ -9,7 +9,36 @@ source: current task evidence

## Current focus

`.\build.ps1` failed in `TestConfigData` with `[-] tests\ConfigData\AzHelpers.Tests.ps1
The active work on this branch is the delta-report pipeline: it adds a
comparison workflow for exported Microsoft365DSC tenant configurations and keeps
it separate from the main export job. The current branch diff shows the main
pieces in `.build/DscConfigurationTasks.ps1`, `.build/Export/DeltaReport.ps1`,
`build.yaml`, and `pipelines/deltaReport.yml`, while `lab/20 Configure AzDo
Project.ps1` remains in the same change set as a related pipeline/project
configuration update. The reports are generated from the exported tenant config
artifacts, merged per tenant with `Join-M365DSCConfiguration`, and then compared
with `New-M365DSCDeltaReport` for each destination tenant.

## Evidence

- `git diff --stat main...HEAD` shows the current branch is changing five files in
the active delta-report work: `.build/DscConfigurationTasks.ps1`, `build.yaml`,
`lab/20 Configure AzDo Project.ps1`, `.build/Export/DeltaReport.ps1`, and
`pipelines/deltaReport.yml`.
- `build.yaml` introduces the `deltaReport` Invoke-Build workflow so the task can
be run independently from the main build and export sequences.
- `.build/DscConfigurationTasks.ps1` adds
`InitializeModuleFolderForDeltaReport` and `NewM365DscDeltaReport`, which
verify input directories, merge exported tenant configs into a source-drift
staging structure, and write the HTML delta reports under `output/DeltaReport`.
- `pipelines/deltaReport.yml` is a dedicated Azure DevOps pipeline definition. It
downloads the export artifact from the upstream export pipeline, initializes the
required modules, runs the delta-report task with a configured source tenant,
and publishes the result as the `DeltaReport` artifact.

## Earlier focus

`\.build.ps1` failed in `TestConfigData` with `[-] tests\ConfigData\AzHelpers.Tests.ps1
failed with: InvalidOperationException: A 'break' or 'continue' statement with a
label that does not match any enclosing loop escaped from your code`. That
message is a Pester 6.1.0 misdiagnosis. The real error is a
Expand Down
24 changes: 21 additions & 3 deletions .memory-bank/progress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
status: current
last-verified: 2026-08-17
last-verified: 2026-09-06
owner: active-agent
source: repository evidence
---
Expand All @@ -9,11 +9,29 @@ source: repository evidence

## Current status

The build is green on `feature/update2608` with the dependency set updated to
August 2026 levels.
The current branch is `feature/deltapipeline` and the active work is the new
export-to-delta-report workflow for comparing tenant configuration drift across
source and destination tenants.

## Recent milestones

- 2026-09-06 Fixed `InitializeModuleFolderForDeltaReport` in
`.build/DscConfigurationTasks.ps1`: `Copy-Item` used
`-ErrorAction SilentlyContinue`, so a failed module copy left a stale
preinstalled module in place and the task finished successfully, letting
`New-M365DSCDeltaReport` generate and publish an incorrect report instead of
failing before artifact publication. Copy failures now throw, naming the
module via `$_.BaseName`.

- 2026-09-06 Added a tenant delta-report workflow. The branch now includes a
`deltaReport` workflow in `build.yaml`, the task implementation in
`.build/DscConfigurationTasks.ps1`, the report generator in
`.build/Export/DeltaReport.ps1`, and the pipeline definition in
`pipelines/deltaReport.yml`. The workflow downloads exported configuration
artifacts, validates the source tenant, merges per-tenant config sets using
`Join-M365DSCConfiguration`, compares them with `New-M365DSCDeltaReport`, and
publishes `output/DeltaReport` as an Azure DevOps artifact.

- 2026-08-17 Fixed `.\build.ps1` failing in `TestConfigData` with Pester 6.1.0's
`A 'break' or 'continue' statement ... escaped from your code`. That message is
a misdiagnosis: Pester 6.1.0 throws it from a `finally` over any terminating
Expand Down
2 changes: 2 additions & 0 deletions .memory-bank/promptHistory.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ source: session interactions
2026-08-07 09:10 UTC | software-engineer | Fix the DSC enact failing with `MSFT_SPOAccessControlSettings ... holds no SharePoint context`, do not commit
2026-08-17 10:00 UTC | software-engineer | Investigate and fix the build failing under Pester 6 with `a 'break' or 'continue' statement ... escaped from your code`, do not commit
2026-08-17 11:10 UTC | software-engineer | Read the test module pins from `RequiredModules.psd1` instead of duplicating them
2026-09-06 11:45 UTC | software-engineer | Analyze the current branch delta and update the Memory Bank to reflect the tenant delta-report workflow
2026-09-06 06:53 UTC | software-engineer | Make Copy-Item failures terminating in InitializeModuleFolderForDeltaReport and include $_.BaseName in the error
11 changes: 11 additions & 0 deletions .memory-bank/systemPatterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ app registrations, the Azure DevOps project and the agent VMs.

## Decisions

### Decision 17: Keep tenant drift reporting as a post-export step

- Choice: Generate the delta report from already-exported tenant configuration
artifacts instead of folding comparison into the export job itself.
- Rationale: The source and destination tenant exports are produced as a set of
`TenantConfig-*` artifacts, and the reporting step merges each tenant's
individual resource configs with `Join-M365DSCConfiguration` before comparing
them with `New-M365DSCDeltaReport`. This keeps the workflow deterministic: the
export pipeline creates the input set, then the reporting pipeline can run on
any artifact bundle without needing live connectivity or a second export pass.

### Decision 1: Use the canonical Memory Bank base

- Choice: Keep durable project context in .memory-bank.
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `InitializeModuleFolderForDeltaReport` in `.build/DscConfigurationTasks.ps1` no
longer silently ignores module copy failures. A failed `Copy-Item` now
throws immediately, naming the module (`$_.BaseName`), instead of letting
the task finish with a stale preinstalled module that could cause
`New-M365DSCDeltaReport` to generate and publish an incorrect report.

### Added

- Initial Upload
Expand Down Expand Up @@ -33,6 +41,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
configured without SharePoint Online, so no `cSPO*` configuration is composed
or compiled for it, and `.build/Export/ExportTenantData.ps1` skips the `SPO*`
components. A new configuration data test guards the mechanism.
- Add a tenant delta-report workflow to compare exported Microsoft365DSC
configurations across source and destination tenants. The new `deltaReport`
Invoke-Build workflow in `build.yaml`, the `NewM365DscDeltaReport` task in
`.build/DscConfigurationTasks.ps1`, the merger in `.build/Export/DeltaReport.ps1`,
and the `pipelines/deltaReport.yml` pipeline now download the export output,
merge each tenant's configuration files with `Join-M365DSCConfiguration`,
generate HTML drift reports with `New-M365DSCDeltaReport`, and publish the
resulting `output/DeltaReport` artifact.

### Changed

Expand Down
4 changes: 4 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ BuildWorkflow:
- LoadDatumConfigData
- ConvertMofToYaml

deltaReport:
- test7
- NewM365DscDeltaReport

build:
- test7
- Clean
Expand Down
2 changes: 1 addition & 1 deletion lab/20 Configure AzDo Project.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ else
# ----------------------------------------------------------

Write-Host 'Creating pipelines in project.'
$pipelineNames = 'build', 'export', 'push', 'reapply', 'test'
$pipelineNames = 'build', 'deltaReport', 'export', 'push', 'reapply', 'test'
foreach ($pipelineName in $pipelineNames)
{
if (Invoke-VSTeamRequest -Area pipelines -Version 7.1 -Method Get -ProjectName $datum.Global.ProjectSettings.ProjectName | Select-Object -ExpandProperty value | Where-Object { $_.name -eq "M365DSC $pipelineName" })
Expand Down
62 changes: 62 additions & 0 deletions pipelines/deltaReport.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
trigger: none

parameters:
- name: sourceTenant
displayName: Source tenant of the delta report
type: string
default: templatetenant.onmicrosoft.com

variables:
buildFolderName: output
defaultBranch: main
Agent.Source.Git.ShallowFetchDepth: 0

resources:
pipelines:
- pipeline: exportPipeline
source: M365DSC export
trigger: none

pool:
vmImage: windows-latest

steps:
- task: PowerShell@2
name: displayEnvVariables
displayName: Display Environment Variables
inputs:
targetType: inline
pwsh: true
script: |
dir -Path env: | Format-Table -Property Name, Value -AutoSize | Out-String | Write-Host
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- download: exportPipeline
patterns: "**"
displayName: Download Exported Tenant Configurations

- task: PowerShell@2
name: InitializeModuleFolder
displayName: Initialize Program Files Modules
inputs:
pwsh: true
filePath: ./build.ps1
arguments: -ResolveDependency -Tasks InitializeModuleFolderForDeltaReport #-UseModuleFast

- task: PowerShell@2
name: deltaReport
displayName: Create Delta Report
inputs:
pwsh: true
filePath: ./build.ps1
arguments: -Tasks deltaReport
env:
DeltaReportInputDirectory: $(Pipeline.Workspace)/exportPipeline
DeltaReportSourceTenant: ${{ parameters.sourceTenant }}

- task: PublishPipelineArtifact@1
displayName: Publish Delta Report
inputs:
targetPath: $(buildFolderName)/DeltaReport
artifact: DeltaReport
publishLocation: pipeline
parallel: true