diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a59cf5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# CLAUDE.md + +## Changelog + +- Every functional change must be documented in [changelog.md](changelog.md) **before committing**. +- Each functional change gets **one line at most** in the changelog (one bullet, single line), ending with the issue/PR number, e.g. `#404`. + +## Tests (Pester) + +- Tests target **Pester 6 and newer**. Use Pester 5/6 syntax only. +- Do **not** use `Assert-MockCalled` or `Assert-VerifiableMock` (removed in Pester 6). Use `Should -Invoke -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. +- Run tests in a clean process with Pester 6 loaded first, e.g.: + + ```powershell + pwsh -NoProfile -Command "Import-Module Pester -MinimumVersion 6.0; Import-Module ./azure.datafactory.tools.psd1; Invoke-Pester -Path ./test/Stop-Trigger.Tests.ps1 -Output Detailed" + ``` diff --git a/azure.datafactory.tools.psd1 b/azure.datafactory.tools.psd1 index 287169e..23b9073 100644 --- a/azure.datafactory.tools.psd1 +++ b/azure.datafactory.tools.psd1 @@ -12,7 +12,7 @@ RootModule = 'azure.datafactory.tools.psm1' # Version number of this module. - ModuleVersion = '1.18.0' + ModuleVersion = '1.19.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/changelog.md b/changelog.md index 1855d2d..b33ae63 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.19.0] - 2026-10-16 +### Added +* Stopping a trigger now retries up to 5 times (and waits while the trigger is provisioning), like starting a trigger #404 + ## [1.18.0] - 2026-07-01 ### Added * `Publish-AdfV2FromJson` now supports terraform-like planning via `-DryRun`/`-Plan`, including structured `DryRunPlan` output (Create/Update/Delete/Unchanged) #360 diff --git a/private/Stop-Trigger.ps1 b/private/Stop-Trigger.ps1 index bc71f57..33dcc80 100644 --- a/private/Stop-Trigger.ps1 +++ b/private/Stop-Trigger.ps1 @@ -1,17 +1,63 @@ function Stop-Trigger { [CmdletBinding()] + [OutputType([bool])] param ( [parameter(Mandatory = $true)] [string] $ResourceGroupName, [parameter(Mandatory = $true)] [string] $DataFactoryName, [parameter(Mandatory = $true)] [string] $Name ) + Write-Debug "BEGIN: Stop-Trigger()" - Write-host "- Disabling trigger: $Name" - - Stop-AzDataFactoryV2Trigger ` - -ResourceGroupName $ResourceGroupName ` - -DataFactoryName $DataFactoryName ` - -Name $Name ` - -Force | Out-Null + Write-Host "- Disabling trigger: $Name" + $attempts = 5; + $i = 0 + $stopped = $false + while ($i -lt $attempts) + { + $i++ + try { + Stop-AzDataFactoryV2Trigger ` + -ResourceGroupName $ResourceGroupName ` + -DataFactoryName $DataFactoryName ` + -Name $Name ` + -Force | Out-Null + # If successful, break out of the loop + $stopped = $true + break + } + catch { + $errMsg = $_.Exception.Message + if ($i -lt $attempts) + { + if ($errMsg -like "*cannot be updated during provisioning*") { + Write-Verbose "Attempt #${i}: Trigger '$Name' is still provisioning. Waiting for provisioning to complete..." + $provisioningTimeout = 300 + $pollInterval = 10 + $elapsed = 0 + while ($elapsed -lt $provisioningTimeout) { + Start-Sleep -Seconds $pollInterval + $elapsed += $pollInterval + $trigger = Get-AzDataFactoryV2Trigger ` + -ResourceGroupName $ResourceGroupName ` + -DataFactoryName $DataFactoryName ` + -Name $Name + $provState = $trigger.Properties.ProvisioningState + Write-Verbose "Trigger provisioning state: $provState ($elapsed sec elapsed)" + if ($provState -ne 'Provisioning') { break } + } + } else { + Write-Verbose "Attempt #$i of stopping trigger failed. Retry in 2 seconds." + Start-Sleep -Seconds 2 + } + } + else + { + Write-Host "Failed stopping trigger after $attempts attempts." + Write-Warning -Message $errMsg + } + } + } + Write-Debug "END: Stop-Trigger()" + return $stopped } diff --git a/private/Stop-Triggers.ps1 b/private/Stop-Triggers.ps1 index 54a3e36..b8f51f4 100644 --- a/private/Stop-Triggers.ps1 +++ b/private/Stop-Triggers.ps1 @@ -52,15 +52,24 @@ function Stop-Triggers { if ($toBeStopped.Count -gt 0) { Write-Host "Stopping deployed triggers:" - $toBeStopped | ForEach-Object { - Stop-Trigger ` + [System.Collections.ArrayList] $failedToStop = @{} + $toBeStopped | ForEach-Object { + $TrName = $_ + $isStopped = Stop-Trigger ` -ResourceGroupName $adf.ResourceGroupName ` -DataFactoryName $adf.Name ` - -Name $_ ` - | Out-Null - - $TrName = $_ - $adf.TargetTriggerNames | Where-Object { $_.Name -eq $TrName } | ForEach-Object { $_.RuntimeState = "Stopped" } + -Name $TrName + + if ($isStopped) { + $adf.TargetTriggerNames | Where-Object { $_.Name -eq $TrName } | ForEach-Object { $_.RuntimeState = "Stopped" } + } else { + $failedToStop.Add($TrName) | Out-Null + } + } + # Keep only triggers that have actually been stopped + $failedToStop | ForEach-Object { $toBeStopped.Remove($_) } + if ($failedToStop.Count -gt 0) { + Write-Warning "The number of triggers that failed to stop: $($failedToStop.Count) ($($failedToStop -join ', '))" } Write-Host "Complete stopping deployed triggers." } diff --git a/test/Stop-Trigger.Tests.ps1 b/test/Stop-Trigger.Tests.ps1 new file mode 100644 index 0000000..6f597f4 --- /dev/null +++ b/test/Stop-Trigger.Tests.ps1 @@ -0,0 +1,103 @@ +BeforeDiscovery { + $ModuleRootPath = $PSScriptRoot | Split-Path -Parent + $moduleManifestName = 'azure.datafactory.tools.psd1' + $moduleManifestPath = Join-Path -Path $ModuleRootPath -ChildPath $moduleManifestName + + Import-Module -Name $moduleManifestPath -Force -Verbose:$false +} + +InModuleScope azure.datafactory.tools { + $testHelperPath = $PSScriptRoot | Join-Path -ChildPath 'TestHelper' + Import-Module -Name $testHelperPath -Force + + Describe 'Command ' -Tag 'Unit' { + It 'Stop-Trigger Should exist' { + { Get-Command -Name Stop-Trigger -ErrorAction Stop } | Should -Not -Throw + } + } + + Describe 'Stop-Trigger' -Tag 'Unit' { + + BeforeEach { + Mock Start-Sleep {} + } + + It 'Should return true when stopped at first attempt' { + Mock Stop-AzDataFactoryV2Trigger { } + $r = Stop-Trigger -ResourceGroupName 'rg' -DataFactoryName 'adf' -Name 'tr1' + $r | Should -BeTrue + Should -Invoke Stop-AzDataFactoryV2Trigger -Times 1 -Exactly + } + + It 'Should retry after the first failure' { + $script:stopAttempt = 0 + Mock Stop-AzDataFactoryV2Trigger { $script:stopAttempt++; if ($script:stopAttempt -eq 1) { throw 'Conflict: concurrent operations' } } + $r = Stop-Trigger -ResourceGroupName 'rg' -DataFactoryName 'adf' -Name 'tr1' + $r | Should -BeTrue + Should -Invoke Stop-AzDataFactoryV2Trigger -Times 2 -Exactly + } + + It 'Should retry max 5 times when failure, warn and return false' { + Mock Stop-AzDataFactoryV2Trigger { throw 'BadRequest' } + Mock Write-Warning {} + $r = Stop-Trigger -ResourceGroupName 'rg' -DataFactoryName 'adf' -Name 'tr1' + $r | Should -BeFalse + Should -Invoke Stop-AzDataFactoryV2Trigger -Times 5 -Exactly + Should -Invoke Write-Warning -Times 1 -Exactly + } + + It 'Should wait for provisioning and retry when trigger cannot be updated during provisioning' { + $script:stopAttempt = 0 + Mock Stop-AzDataFactoryV2Trigger { + $script:stopAttempt++ + if ($script:stopAttempt -eq 1) { throw 'BadRequest: Resource cannot be updated during provisioning' } + } + $script:getAttempt = 0 + Mock Get-AzDataFactoryV2Trigger { + $script:getAttempt++ + $state = if ($script:getAttempt -eq 1) { 'Provisioning' } else { 'Succeeded' } + return [PSCustomObject]@{ Properties = [PSCustomObject]@{ ProvisioningState = $state } } + } + + $r = Stop-Trigger -ResourceGroupName 'rg' -DataFactoryName 'adf' -Name 'tr1' + + $r | Should -BeTrue + Should -Invoke Stop-AzDataFactoryV2Trigger -Times 2 -Exactly + Should -Invoke Get-AzDataFactoryV2Trigger -Times 2 -Exactly + } + } + + Describe 'Stop-Triggers when a trigger fails to stop' -Tag 'Unit' { + + BeforeAll { + Mock Get-SortedTriggers { + @( + [PSCustomObject]@{ Name = 'tr1'; RuntimeState = 'Started' } + [PSCustomObject]@{ Name = 'tr2'; RuntimeState = 'Started' } + ) + } + Mock Stop-Trigger { param ($ResourceGroupName, $DataFactoryName, $Name) return ($Name -ne 'tr2') } + Mock Write-Warning {} + + $script:adf = [Adf]::new() + $script:adf.Name = 'adf' + $script:adf.ResourceGroupName = 'rg' + $script:adf.PublishOptions = New-AdfPublishOption + Stop-Triggers -adf $script:adf + } + + It 'Should try to stop all active triggers' { + Should -Invoke Stop-Trigger -Times 2 -Exactly -Scope Describe + } + It 'Should register only successfully stopped triggers' { + $script:adf.StoppedTriggerNames | Should -Be @('tr1') + } + It 'Should keep the RuntimeState of trigger which failed to stop' { + ($script:adf.TargetTriggerNames | Where-Object Name -eq 'tr1').RuntimeState | Should -Be 'Stopped' + ($script:adf.TargetTriggerNames | Where-Object Name -eq 'tr2').RuntimeState | Should -Be 'Started' + } + It 'Should warn about triggers which failed to stop' { + Should -Invoke Write-Warning -Times 1 -Exactly -Scope Describe -ParameterFilter { $Message -like '*tr2*' } + } + } +} diff --git a/test/Triggers.Tests.ps1 b/test/Triggers.Tests.ps1 index e97cca8..1f940fd 100644 --- a/test/Triggers.Tests.ps1 +++ b/test/Triggers.Tests.ps1 @@ -39,6 +39,7 @@ InModuleScope azure.datafactory.tools { $o = $script:TargetAdf.GetObjectByFullName("*triggers.$Name") $o.RuntimeState = 'Stopped' $o.Body.properties.RuntimeState = $o.RuntimeState + return $true } Mock Start-Trigger { param ($ResourceGroupName, $DataFactoryName, $Name)