Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@

## Tests (Pester)

- Set up a local environment with `./tools/Install-DevPrerequisites.ps1`: it installs the Az modules, Pester 6+ and PSScriptAnalyzer for the current user (only what is missing) and checks Node.js/npm.
- Tests require **Pester 6.0 or newer** (enforced by `test/!RunAllTests.ps1`, which installs it on CI runners).
- Do **not** use `Assert-MockCalled` or `Assert-VerifiableMock` (removed in Pester 6). Use `Should -Invoke <command> -Times N -Exactly` instead; add `-Scope Describe` when the mocked command was called in `BeforeAll`.
- Reason: calling `Assert-MockCalled` makes PowerShell autoload the old built-in Pester 3.4.0 (`C:\Program Files\WindowsPowerShell\Modules`), which takes over `Mock`/`Should` and breaks the whole test run.
- Every test that needs access to Azure (a real factory, storage or any Az call that is not mocked) must be in a `Describe` tagged `Integration`; all other tests must run without `Connect-AzAccount`. Put Azure calls in `BeforeAll`/`It`, never in the file or `Describe` body: that code runs at discovery, before tag filters apply.
- **Bug fixes start with a test** that reproduces the bug; run it and see it fail before writing the fix.
- Mocks of Az cmdlets must `Write-Error` (non-terminating, like the real cmdlets), not `throw`. Mocks that `throw` hid the bugs in #508 and #295.
- CI runs with `$ErrorActionPreference = 'Stop'`. Run new or changed tests under both `Stop` and `Continue`; set `$ErrorActionPreference = 'Continue'` in `BeforeEach` to test the default behaviour.
Expand Down
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
* Deleting objects not in source retries temporary API errors and stops on an unsupported object type whatever `$ErrorActionPreference` is #30
* A failed object deployment now stops `Publish-AdfV2FromJson` whatever `$ErrorActionPreference` is, instead of being skipped and the object marked as deployed #295
* Starting and stopping triggers now catches Az errors whatever `$ErrorActionPreference` is, so retries and warnings work; a failed start is a warning and the deployment carries on #508
* `DeleteNotInSource`: objects skipped as excluded are no longer counted as deleted, and each skipped object is logged #512
* `DeleteNotInSource` and `-DryRun`: `@folder` filter rules now match objects that exist only in ADF (folder read via REST API), and a rule without `@folder` matches any folder #513
* `Export-AdfToArmTemplate` calls `npm.cmd` on Windows (avoids npm.ps1 failing on Windows PowerShell 5.1) and stops when an npm command fails #462

## [1.19.0] - 2026-10-16
Expand Down
5 changes: 4 additions & 1 deletion en-us/messages_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ ADFT0029 | Unknown object type: *type*.
ADFT0030 | AzType '$AzType' is not supported.
ADFT0031 | Empty value in config file. Path: [*path*]. Check previous warnings.
ADFT0032 | The process is exiting the function. Do fix the issue and run again.
ADFT0033 | Incremental Deployment Option DISABLED as Storage Uri is not provided.
ADFT0033 | Incremental Deployment Option DISABLED as Storage Uri is not provided.
ADFT0034 | *[type].[name]* cannot be deleted since it is referenced by *name*, which is not selected for deletion.
ADFT0035 | Unexpected response code: *code* from REST API when listing *types*.
ADFT0036 | Unable to read folder of *[type].[name]* from ADF service. Status Code: *code*
8 changes: 8 additions & 0 deletions private/!AdfPublishOption.class.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,12 @@ class AdfPublishOption {
[TriggerStopTypes] $TriggerStopMethod = [TriggerStopTypes]::AllEnabled
[TriggerStartTypes] $TriggerStartMethod = [TriggerStartTypes]::BasedOnSourceCode
[Int32] $MaxRetries = 3

[Boolean] HasFolderRules()
{
foreach ($rule in @($this.Includes.Keys) + @($this.Excludes.Keys)) {
if ($rule -like '*@*') { return $true }
}
return $false
}
}
33 changes: 20 additions & 13 deletions private/AdfObjectName.class.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -65,29 +65,36 @@ class AdfObjectName {
return $null -ne $r
}

[Boolean] IsNameExcluded ([AdfPublishOption] $opt)
# Same rule semantics as AdfObject.IsNameMatch: 'type.name[@folder]', folder is checked only when the rule has one
[Boolean] IsRuleMatch ([string] $rule)
{
$fullname = $this.FullNameWithFolder()
$arr = $rule.Split('@')
$r = $this.FullName() -like $arr[0]
if ($arr.Count -gt 1) {
$r = $r -and ($this.Folder -like $arr[1])
}
return $r
}

[Boolean] IsNameExcluded ([AdfPublishOption] $opt)
{
# One can exclude objects by listing them explicitly in Excludes collection, ...
$excPatterns = $opt.Excludes.Keys
$r = $excPatterns | Where-Object { $fullname -like $_ }
if ($null -ne $r)
{
foreach ($rule in $opt.Excludes.Keys) {
# Means: object is excluded if matches any item in (Excludes) collection
return $true
if ($this.IsRuleMatch($rule)) { return $true }
}

# ... or by listing them implicitly in Includes collection:
$incPatterns = $opt.Includes.Keys
if ($incPatterns.Count -eq 0)
{
if ($opt.Includes.Keys.Count -eq 0)
{
# If no items = all objects match => object is not excluded
return $false
return $false
}
foreach ($rule in $opt.Includes.Keys) {
if ($this.IsRuleMatch($rule)) { return $false }
}
$r = $incPatterns | Where-Object { $fullname -like $_ }
# Means: object is excluded if not match any item in (Includes) collection
return ($null -eq $r)
return $true
}

}
3 changes: 3 additions & 0 deletions private/ApplyExclusionOptions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ function Get-DryRunPlan {
$oname = [AdfObjectName]::new($fullName)
$canDelete = $true
if ($adf.PublishOptions.DoNotDeleteExcludedObjects) {
if ($adf.PublishOptions.HasFolderRules()) {
$oname.Folder = Get-AdfObjectFolderFromService -adfInstance $targetAdfInstance -type $oname.Type -name $oname.Name
}
$canDelete = !($oname.IsNameExcluded($adf.PublishOptions))
}
if ($canDelete) {
Expand Down
40 changes: 40 additions & 0 deletions private/Get-AdfObjectFolderFromService.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
function Get-AdfObjectFolderFromService {
[CmdletBinding()]
[OutputType([string])]
param (
[parameter(Mandatory = $true)] $adfInstance,
[parameter(Mandatory = $true)] [String] $type,
[parameter(Mandatory = $true)] [String] $name
)

Write-Debug "BEGIN: Get-AdfObjectFolderFromService(type=$type, name=$name)"

# Only these types have folders. Az cmdlets do not return the folder of a pipeline, hence REST API
$typePlural = switch ($type) {
'Pipeline' { 'pipelines' }
'Dataset' { 'datasets' }
'DataFlow' { 'dataflows' }
default { $null }
}
if ($null -eq $typePlural) {
return ''
}

$url = "$($script:BaseApiUrl)$($adfInstance.Id)/$typePlural/$($name)?api-version=2018-06-01"
$r = Invoke-AdfWithRetry -Activity "Reading folder of [$type].[$name]" -ScriptBlock {
$resp = Invoke-AzRestMethod -Method 'GET' -Uri $url -ErrorAction Stop
if ($resp.StatusCode -ne 200) {
throw "ADFT0036: Unable to read folder of [$type].[$name] from ADF service. Status Code: $($resp.StatusCode)"
}
$resp
}

$folder = ''
$props = ($r.Content | ConvertFrom-Json).properties
if ($null -ne $props -and 'folder' -in $props.PSObject.Properties.Name) {
$folder = $props.folder.name
}

Write-Debug "END: Get-AdfObjectFolderFromService() = '$folder'"
return $folder
}
14 changes: 10 additions & 4 deletions private/Remove-AdfObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@ function Remove-AdfObject {
if ($null -eq $adfSource) {
Write-Host "Removing object: [$simtype].[$name]"
} else {
[AdfObjectName] $oname = [AdfObjectName]::new("$simType.$name")
# The folder is only needed (and read from the service) when any filter rule uses '@folder'
$folder = ''
if ($adfSource.PublishOptions.HasFolderRules()) {
$folder = Get-AdfObjectFolderFromService -adfInstance $adfInstance -type $simtype -name $name
}
[AdfObjectName] $oname = [AdfObjectName]::new($name, $simtype, $folder)
$IsExcluded = $oname.IsNameExcluded($adfSource.PublishOptions)
if (-not $IsExcluded) {
Write-Host "Removing object: [$simtype].[$name]"
} else {
if ($adfSource.PublishOptions.DoNotDeleteExcludedObjects) {
Write-Verbose "Object [$simtype].[$name] won't be deleted as publish option 'DoNotDeleteExcludedObjects' = true."
Write-Host "Skipping excluded object: [$simtype].[$name] as publish option 'DoNotDeleteExcludedObjects' = true."
$action = "DoNothing"
} else {
Write-Host "Removing excluded object: [$simtype].[$name] as publish option 'DoNotDeleteExcludedObjects' = false."
Expand Down Expand Up @@ -141,8 +146,9 @@ function Remove-AdfObject {
} elseif ($null -ne $ErrorMessage) {
#Rethrow exception
throw $ErrorMessage
} elseif ($null -ne $Deleted -and $action -ne 'DoNothing') {
$Deleted.Add($obj) | Out-Null
} elseif ($action -ne 'DoNothing') {
if ($null -ne $Deleted) { $Deleted.Add($obj) | Out-Null }
if ($null -ne $adfSource) { $adfSource.DeletedObjectNames.Add("$simtype.$name") | Out-Null }
}

Write-Debug "END: Remove-AdfObject()"
Expand Down
8 changes: 6 additions & 2 deletions private/Remove-AdfObjectIfNotInSource.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ function Remove-AdfObjectIfNotInSource {
$simtype = ConvertTo-AdfType -AzType $adfTargetObj.Type
}
$src = Get-AdfObjectByName -adf $adfSource -name $name -type $simtype
if (!$src)
if ($adfSource.IsObjectDeleted("$simtype.$name"))
{
Write-Verbose "Object [$simtype].[$name] has already been deleted (it referenced another deleted object)."
}
elseif (!$src)
{
Write-Verbose "Object [$simtype].[$name] hasn't been found in the source - to be deleted."
# Remove-AdfObject adds the object to DeletedObjectNames only when it has really been deleted
Remove-AdfObject -adfSource $adfSource -obj $adfTargetObj -adfInstance $adfInstance
$adfSource.DeletedObjectNames.Add("$simtype.$name")
}
else {
Write-Verbose "Object [$simtype].[$name] is in the source - won't be delete."
Expand Down
4 changes: 2 additions & 2 deletions test/Incremental-Deployment.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ InModuleScope azure.datafactory.tools {
return $script:TargetAdf
}
Mock Set-AzDataFactoryV2 {
param ($ResourceGroupName, $DataFactoryName, $Location)
param ($ResourceGroupName, $Name, $Location)
if ($null -eq $script:TargetAdf) {
$script:TargetAdf = CreateTargetAdf
$script:TargetAdf.Name = $DataFactoryName
$script:TargetAdf.Name = $Name
$script:TargetAdf.ResourceGroupName = $ResourceGroupName
$script:TargetAdf.Location = $Location
}
Expand Down
3 changes: 1 addition & 2 deletions test/Integration.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ InModuleScope azure.datafactory.tools {
# Variables for use in tests
$script:ResourceGroupName = 'rg-devops-factory'
$script:Stage = 'UAT'
$c = Get-AzContext
$script:guid = $c.Subscription.Id.Substring(0,8)
$script:guid = Get-TestSubscriptionPrefix
$script:DataFactoryOrigName = 'BigFactorySample2'
$script:DataFactoryName = $script:DataFactoryOrigName + "-$guid"
$script:SrcFolder = "$PSScriptRoot\$($script:DataFactoryOrigName)"
Expand Down
2 changes: 1 addition & 1 deletion test/Publish-AdfV2FromJson-1.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ InModuleScope azure.datafactory.tools {
}
}

Describe 'Publish-AdfV2FromJson' {
Describe 'Publish-AdfV2FromJson' -Tag 'Integration' {
It 'adf2 Should skip referenced Synapse notebook in a pipeline' {
$script:RootFolder = Join-Path $PSScriptRoot "adf2"
$o = New-AdfPublishOption
Expand Down
14 changes: 11 additions & 3 deletions test/Publish-AdfV2FromJson-2.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ InModuleScope azure.datafactory.tools {
$script:DataFactoryOrigName = $t.DataFactoryOrigName
$script:DataFactoryName = $t.DataFactoryName
$script:Location = $t.Location
$script:adfi = Get-AzDataFactoryV2 -ResourceGroupName "$ResourceGroupName" -Name "$DataFactoryName"
#$script:RootFolder = Join-Path $PSScriptRoot "BigFactorySample2"

$script:opt = New-AdfPublishOption
Expand All @@ -35,12 +34,21 @@ InModuleScope azure.datafactory.tools {
It 'Should exist' {
{ Get-Command -Name Get-AzDFV2Credential -ErrorAction Stop } | Should -Not -Throw
}
}

Describe 'Get-AzDFV2Credential' -Tag 'Integration' {
BeforeAll {
$script:adfi = Get-AzDataFactoryV2 -ResourceGroupName "$ResourceGroupName" -Name "$DataFactoryName"
}
It 'Should run successfully' {
{ Get-AzDFV2Credential -adfi $adfi | ToArray } | Should -Not -Throw
}
}
}

Describe 'Publish-AdfV2FromJson' {
Describe 'Publish-AdfV2FromJson' -Tag 'Integration' {
BeforeAll {
$script:adfi = Get-AzDataFactoryV2 -ResourceGroupName "$ResourceGroupName" -Name "$DataFactoryName"
}
It 'BigFactorySample2 should deploy credential object successfully' {
$o = New-AdfPublishOption
$o.StopStartTriggers = $false
Expand Down
23 changes: 13 additions & 10 deletions test/Remove-AdfObject.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,24 @@ InModuleScope azure.datafactory.tools {
$script:ResourceGroupName = $t.ResourceGroupName
$script:Stage = 'UAT'
$script:RootFolder = Join-Path $PSScriptRoot $t.DataFactoryOrigName
$script:adf = Import-AdfFromFolder -FactoryName $script:DataFactoryName -RootFolder $script:RootFolder
$adf.ResourceGroupName = $t.ResourceGroupName
$adf.Region = $t.Location
$o = New-AdfPublishOption
$o.StopStartTriggers = $false
$adf.PublishOptions = $o
Publish-AdfV2FromJson -RootFolder $RootFolder -DataFactoryName $DataFactoryName -ResourceGroupName $ResourceGroupName -Option $o -Location $Location
$script:adfIns = Get-AdfFromService -FactoryName $DataFactoryName -ResourceGroupName $ResourceGroupName


Describe 'Remove-AdfObject' {
Describe 'Remove-AdfObject' -Tag 'Unit' {
It 'Should exist' {
{ Get-Command -Name 'Remove-AdfObject' -ErrorAction Stop } | Should -Not -Throw
}
}

Describe 'Remove-AdfObject' -Tag 'Integration' {
BeforeAll {
$script:adf = Import-AdfFromFolder -FactoryName $script:DataFactoryName -RootFolder $script:RootFolder
$adf.ResourceGroupName = $script:ResourceGroupName
$adf.Region = $script:Location
$o = New-AdfPublishOption
$o.StopStartTriggers = $false
$adf.PublishOptions = $o
Publish-AdfV2FromJson -RootFolder $RootFolder -DataFactoryName $DataFactoryName -ResourceGroupName $ResourceGroupName -Option $o -Location $Location
$script:adfIns = Get-AdfFromService -FactoryName $DataFactoryName -ResourceGroupName $ResourceGroupName
}

Context 'When called for dep object but it is excluded' {
It 'Should not delete any object' {
Expand Down
Loading
Loading