diff --git a/private/functions/Get-AgentJobFingerprint.ps1 b/private/functions/Get-AgentJobFingerprint.ps1 new file mode 100644 index 00000000000..4dd2f2b21ef --- /dev/null +++ b/private/functions/Get-AgentJobFingerprint.ps1 @@ -0,0 +1,117 @@ +function Get-AgentJobFingerprint { + <# + .SYNOPSIS + Internal function. Builds a content fingerprint of a SQL Agent job so two jobs can be compared by definition rather than by date_modified. + + .DESCRIPTION + Normalises the job properties, steps and schedules into a deterministic string and returns a SHA256 hash of it, + plus the per-section text so callers can report which part differs. + + Deliberately excluded because they differ between instances even when the definition is identical: + job_id, date_created, date_modified, version_number, schedule_id, schedule_uid, originating server, + run history/status, and the job name (the caller already matched by name and may be using -NewName). + + Job-level IsEnabled is part of the fingerprint but kept in its own section so the caller can + align it in place rather than recreating the job when nothing else differs. + + .PARAMETER Job + The SMO job object. + + .PARAMETER OwnerLoginName + Overrides the owner used in the fingerprint. Used when -Force is remapping a missing owner to sa on the destination. + + .PARAMETER IsEnabled + Overrides the enabled state used in the fingerprint. Used when -DisableOnDestination means the desired + destination state is disabled regardless of the source. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [Microsoft.SqlServer.Management.Smo.Agent.Job]$Job, + [string]$OwnerLoginName, + [bool]$IsEnabled + ) + + $owner = if ($OwnerLoginName) { $OwnerLoginName } else { $Job.OwnerLoginName } + $enabledValue = if ($PSBoundParameters.ContainsKey("IsEnabled")) { $IsEnabled } else { $Job.IsEnabled } + $enabled = "IsEnabled=$enabledValue" + + # Proxy accounts were introduced in SQL Server 2005; ProxyName is not readable on 2000 + $supportsProxy = $Job.Parent.Parent.VersionMajor -ge 9 + + $jobProps = @( + "Owner=$owner" + "Category=$($Job.Category)" + "Description=$(($Job.Description -replace "`r`n", "`n").TrimEnd())" + "StartStepID=$($Job.StartStepID)" + "EmailLevel=$($Job.EmailLevel)" + "OperatorToEmail=$($Job.OperatorToEmail)" + "PageLevel=$($Job.PageLevel)" + "OperatorToPage=$($Job.OperatorToPage)" + "NetSendLevel=$($Job.NetSendLevel)" + "OperatorToNetSend=$($Job.OperatorToNetSend)" + "EventLogLevel=$($Job.EventLogLevel)" + "DeleteLevel=$($Job.DeleteLevel)" + ) -join "`n" + + $stepProps = foreach ($step in ($Job.JobSteps | Sort-Object ID)) { + @( + "ID=$($step.ID)" + "Name=$($step.Name)" + "SubSystem=$($step.SubSystem)" + "Command=$(($step.Command -replace "`r`n", "`n").TrimEnd())" + "DatabaseName=$($step.DatabaseName)" + "DatabaseUserName=$($step.DatabaseUserName)" + "Server=$($step.Server)" + "ProxyName=$(if ($supportsProxy) { $step.ProxyName })" + "OnSuccessAction=$($step.OnSuccessAction)" + "OnSuccessStep=$($step.OnSuccessStep)" + "OnFailAction=$($step.OnFailAction)" + "OnFailStep=$($step.OnFailStep)" + "RetryAttempts=$($step.RetryAttempts)" + "RetryInterval=$($step.RetryInterval)" + "OutputFileName=$($step.OutputFileName)" + "JobStepFlags=$($step.JobStepFlags)" + "CommandExecutionSuccessCode=$($step.CommandExecutionSuccessCode)" + "OSRunPriority=$($step.OSRunPriority)" + ) -join "`n" + } + $steps = $stepProps -join "`n--`n" + + $scheduleProps = foreach ($sched in $Job.JobSchedules) { + @( + "Name=$($sched.Name)" + "IsEnabled=$($sched.IsEnabled)" + "FrequencyTypes=$($sched.FrequencyTypes)" + "FrequencyInterval=$($sched.FrequencyInterval)" + "FrequencySubDayTypes=$($sched.FrequencySubDayTypes)" + "FrequencySubDayInterval=$($sched.FrequencySubDayInterval)" + "FrequencyRelativeIntervals=$($sched.FrequencyRelativeIntervals)" + "FrequencyRecurrenceFactor=$($sched.FrequencyRecurrenceFactor)" + "ActiveStartDate=$($sched.ActiveStartDate.ToString("yyyyMMdd"))" + "ActiveEndDate=$($sched.ActiveEndDate.ToString("yyyyMMdd"))" + "ActiveStartTimeOfDay=$($sched.ActiveStartTimeOfDay)" + "ActiveEndTimeOfDay=$($sched.ActiveEndTimeOfDay)" + ) -join "`n" + } + # Sorted by the full normalised text: schedule names are not unique per job, and Sort-Object + # is not guaranteed to be stable in Windows PowerShell, so sorting by name alone could yield + # different fingerprints for the same job + $schedules = ($scheduleProps | Sort-Object) -join "`n--`n" + + $normalised = "[job]`n$jobProps`n$enabled`n[steps]`n$steps`n[schedules]`n$schedules" + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($normalised)) | ForEach-Object { $_.ToString("x2") }) -join "" + } finally { + $sha.Dispose() + } + + [PSCustomObject]@{ + Hash = $hash + Job = $jobProps + Enabled = $enabled + Steps = $steps + Schedules = $schedules + } +} diff --git a/public/Copy-DbaAgentJob.ps1 b/public/Copy-DbaAgentJob.ps1 index a5331bc8a9c..5017c5ae008 100644 --- a/public/Copy-DbaAgentJob.ps1 +++ b/public/Copy-DbaAgentJob.ps1 @@ -38,6 +38,8 @@ function Copy-DbaAgentJob { .PARAMETER DisableOnSource Disables the job on the source server after successfully copying it to the destination. Use this during server migrations or failover scenarios where you want to prevent the job from running on the old server while it runs on the new one. + With -UseLastModified it also applies when the job was found identical on the destination or only its enabled state was aligned; + it does not apply when the job was skipped because the destination copy is newer. .PARAMETER DisableOnDestination Creates the job on the destination server but leaves it disabled. @@ -63,12 +65,19 @@ function Copy-DbaAgentJob { Cannot be used when copying multiple jobs simultaneously. .PARAMETER UseLastModified - When enabled, compares the last modification date (date_modified) from msdb.dbo.sysjobs between source and destination instances. - Jobs are only copied or updated if the source job is newer than the destination job. This provides intelligent synchronization: - - If job doesn't exist on destination: creates it - - If source date_modified is newer: drops and recreates the job - - If dates are equal: skips the job - - If destination is newer: skips with a warning + Compares the job definition on source and destination - job properties, enabled state, steps and schedules - and only copies when they actually differ. + When the definitions differ, the direction is decided by each job's effective last-modified time: the later of msdb.dbo.sysjobs.date_modified and + the date_modified of every schedule attached to the job (sp_update_schedule only touches sysschedules, not the job row). On SQL Server 2000, + which has no sysschedules, only the job row is used. Both values are normalised to UTC using each server's current time zone offset so + instances in different time zones compare correctly; across a daylight-saving change the value for a job modified before the switch can be + off by one hour, which only matters when source and destination are in different time zones. + - Job doesn't exist on destination: creates it + - Definitions identical: skips, regardless of timestamps + - Only the enabled state differs and source is not older: updates the flag in place without recreating the job + - Definitions differ and source is newer (or equal): drops and recreates the job + - Definitions differ and destination is newer: skips with a warning + Job IDs, timestamps, version numbers, schedule IDs/UIDs and run history are excluded from the comparison, so jobs that are + identical but were created independently (for example on AG replicas) are not needlessly recreated. Use this for incremental synchronization scenarios where you want to keep jobs up-to-date without unconditionally overwriting them. .PARAMETER EnableException @@ -124,7 +133,7 @@ function Copy-DbaAgentJob { .EXAMPLE PS C:\> Copy-DbaAgentJob -Source sqlserver2014a -Destination sqlserver2014b -UseLastModified - Copies jobs from sqlserver2014a to sqlserver2014b, but only creates new jobs or updates existing jobs where the source job has a newer date_modified timestamp. Jobs with matching timestamps are skipped. + Copies jobs from sqlserver2014a to sqlserver2014b, creating jobs that don't exist and recreating only those whose definition differs and where the source is not older. Jobs with an identical definition are skipped even when their date_modified values differ. A job that differs only in its enabled state has the flag updated in place. .EXAMPLE PS C:\> Copy-DbaAgentJob -Source sqlserver2014a -Destination sqlserver2014a -Job "OriginalJob" -NewName "JobCopy" @@ -173,6 +182,30 @@ function Copy-DbaAgentJob { return } if ($Force) { $ConfirmPreference = 'none' } + + # Effective last-modified for -UseLastModified, normalised to UTC with the server's current offset. + # DATEDIFF against GETUTCDATE works on SQL Server 2000+ (SYSDATETIMEOFFSET would need 2008+). + # sp_update_schedule stamps sysschedules.date_modified only, never sysjobs.date_modified, so + # schedules must be included on 2005+; SQL Server 2000 has no sysschedules and uses the job row alone. + $sqlLastModified = " +SELECT DATEADD(MINUTE, -DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()), MAX(x.date_modified)) AS LastModifiedUtc +FROM ( + SELECT j.date_modified + FROM msdb.dbo.sysjobs AS j + WHERE j.job_id = @jobId + UNION ALL + SELECT s.date_modified + FROM msdb.dbo.sysjobschedules AS js + INNER JOIN msdb.dbo.sysschedules AS s ON s.schedule_id = js.schedule_id + WHERE js.job_id = @jobId +) AS x" + $sqlLastModified2000 = " +SELECT DATEADD(MINUTE, -DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()), j.date_modified) AS LastModifiedUtc +FROM msdb.dbo.sysjobs AS j +WHERE j.job_id = @jobId" + + # Destinations whose job collection has been refreshed during this invocation (-UseLastModified only) + $refreshedDestinations = @{} } process { if (Test-FunctionInterrupt) { return } @@ -182,13 +215,27 @@ function Copy-DbaAgentJob { } catch { Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $destinstance -Continue } + if ($UseLastModified -and -not $refreshedDestinations.ContainsKey($destServer.Name)) { + # dbatools reuses server objects within a session; refresh once so new/dropped jobs are seen on repeat runs + $destServer.JobServer.Jobs.Refresh() + $refreshedDestinations[$destServer.Name] = $true + } $destJobs = $destServer.JobServer.Jobs foreach ($serverJob in $InputObject) { + if ($UseLastModified) { + # SMO caches and dbatools reuses server objects within a session; refresh the source job + # before the dependency checks, the comparison and Script() read from it + $serverJob.Refresh() + $serverJob.JobSteps.Refresh($true) + $serverJob.JobSchedules.Refresh($true) + } + $jobName = $serverJob.Name $jobId = $serverJob.JobId $sourceserver = $serverJob.Parent.Parent $alertsReferencingJob = @() + $skipCreate = $false $destJobName = if (Test-Bound "NewName") { $NewName } else { $jobName } if ($sourceserver.Name -eq $destServer.Name -and -not (Test-Bound "NewName")) { @@ -225,7 +272,7 @@ function Copy-DbaAgentJob { $MaintenancePlanName = $sourceServer.Query($sql).MaintenancePlanName if ($MaintenancePlanName) { - if ($Pscmdlet.ShouldProcess($destinstance, "Job [$jobName] is associated with Maintenance Plan: $MaintenancePlanNam")) { + if ($Pscmdlet.ShouldProcess($destinstance, "Job [$jobName] is associated with Maintenance Plan: $MaintenancePlanName")) { $copyJobStatus.Status = "Skipped" $copyJobStatus.Notes = "Job is associated with maintenance plan" $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject @@ -312,79 +359,120 @@ function Copy-DbaAgentJob { if ($destJobs.name -contains $destJobName) { if ($UseLastModified) { - # Query date_modified from both source and destination using parameterized queries try { - $splatSourceDate = @{ - SqlInstance = $sourceserver - Database = "msdb" - Query = "SELECT date_modified FROM dbo.sysjobs WHERE name = @jobName" - SqlParameter = @{ jobName = $jobName } + $destJob = $destServer.JobServer.Jobs[$destJobName] + $destJob.Refresh() + $destJob.JobSteps.Refresh($true) + $destJob.JobSchedules.Refresh($true) + + # Compare the definitions first. Timestamps only decide direction when the + # definitions actually differ; on their own they are never a reason to copy. + $splatFingerprint = @{ Job = $serverJob } + if ($missingLogin.Count -gt 0) { + # -Force remaps a missing owner to sa on the destination, so compare against that + $splatFingerprint["OwnerLoginName"] = Get-SqlSaLogin -SqlInstance $destServer } - $sourceDate = (Invoke-DbaQuery @splatSourceDate).date_modified - - $splatDestDate = @{ - SqlInstance = $destServer - Database = "msdb" - Query = "SELECT date_modified FROM dbo.sysjobs WHERE name = @jobName" - SqlParameter = @{ jobName = $destJobName } + if ($DisableOnDestination) { + # desired destination state is disabled regardless of the source + $splatFingerprint["IsEnabled"] = $false } - $destDate = (Invoke-DbaQuery @splatDestDate).date_modified + $sourcePrint = Get-AgentJobFingerprint @splatFingerprint + $destPrint = Get-AgentJobFingerprint -Job $destJob + + if ($sourcePrint.Hash -eq $destPrint.Hash) { + if ($Pscmdlet.ShouldProcess($destinstance, "Job $destJobName has an identical definition on source and destination. Skipping.")) { + $copyJobStatus.Status = "Skipped" + $copyJobStatus.Notes = "Job definition is identical on source and destination" + $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject + Write-Message -Level Verbose -Message "Job $destJobName has an identical definition on source and destination. Skipping." + } + # Nothing to create; fall through so -DisableOnSource still applies + $skipCreate = $true + } else { + $changed = @() + if ($sourcePrint.Job -cne $destPrint.Job) { $changed += "job properties" } + if ($sourcePrint.Enabled -cne $destPrint.Enabled) { $changed += "enabled state" } + if ($sourcePrint.Steps -cne $destPrint.Steps) { $changed += "steps" } + if ($sourcePrint.Schedules -cne $destPrint.Schedules) { $changed += "schedules" } + $changedText = $changed -join ", " + + # Effective last-modified (job row + attached schedules on 2005+), already in UTC + $sourceQuery = if ($sourceserver.VersionMajor -lt 9) { $sqlLastModified2000 } else { $sqlLastModified } + $destQuery = if ($destServer.VersionMajor -lt 9) { $sqlLastModified2000 } else { $sqlLastModified } + $splatSourceDate = @{ + SqlInstance = $sourceserver + Database = "msdb" + Query = $sourceQuery + SqlParameter = @{ jobId = $serverJob.JobID } + } + $sourceDate = (Invoke-DbaQuery @splatSourceDate).LastModifiedUtc + $splatDestDate = @{ + SqlInstance = $destServer + Database = "msdb" + Query = $destQuery + SqlParameter = @{ jobId = $destJob.JobID } + } + $destDate = (Invoke-DbaQuery @splatDestDate).LastModifiedUtc - if ($null -eq $sourceDate -or $null -eq $destDate) { - Write-Message -Level Warning -Message "Could not retrieve date_modified for job $jobName. Skipping date comparison." - if ($force -eq $false) { - if ($Pscmdlet.ShouldProcess($destinstance, "Job $jobName exists at destination. Use -Force to drop and migrate.")) { + if ($destDate -gt $sourceDate) { + if ($Pscmdlet.ShouldProcess($destinstance, "Job $destJobName differs ($changedText) but is newer on destination. Skipping.")) { $copyJobStatus.Status = "Skipped" - $copyJobStatus.Notes = "Already exists on destination" + $copyJobStatus.Notes = "Definition differs ($changedText) but destination job is newer than source (dest: $destDate UTC, source: $sourceDate UTC)" $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject - Write-Message -Level Verbose -Message "Job $jobName exists at destination. Use -Force to drop and migrate." + Write-Message -Level Warning -Message "Job $destJobName differs from source ($changedText) but is newer on destination ($destDate UTC) than source ($sourceDate UTC). Skipping. Use -Force without -UseLastModified to overwrite." } continue } - } elseif ($sourceDate -gt $destDate) { - # Source is newer, proceed with drop and recreate - if ($Pscmdlet.ShouldProcess($destinstance, "Source job is newer (modified $sourceDate). Dropping and recreating job $destJobName")) { - try { - Write-Message -Message "Source job $jobName is newer. Dropping and recreating $destJobName." -Level Verbose - # Before dropping, save which alerts reference this job - $splatAlertsForJob = @{ - SqlInstance = $destServer - Database = "msdb" - Query = "SELECT name FROM dbo.sysalerts WHERE job_id = (SELECT job_id FROM dbo.sysjobs WHERE name = @jobName)" - SqlParameter = @{ jobName = $destJobName } + + if ($changed.Count -eq 1 -and $changed[0] -eq "enabled state") { + # Only the enabled flag differs: align it in place instead of dropping and recreating + $targetEnabled = if ($DisableOnDestination) { $false } else { $serverJob.IsEnabled } + if ($Pscmdlet.ShouldProcess($destinstance, "Job $destJobName differs only in enabled state. Setting IsEnabled to $targetEnabled.")) { + try { + $destJob.IsEnabled = $targetEnabled + $destJob.Alter() + $copyJobStatus.Status = "Successful" + $copyJobStatus.Notes = "Enabled state set to $targetEnabled in place; definition otherwise identical, job not recreated" + $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject + Write-Message -Level Verbose -Message "Job $destJobName differs only in enabled state. Set IsEnabled to $targetEnabled without recreating." + } catch { + $copyJobStatus.Status = "Failed" + $copyJobStatus.Notes = (Get-ErrorMessage -Record $_).Message + $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject + Write-Message -Level Verbose -Message "Issue updating enabled state for job $destJobName on $destinstance | $PSItem" + # Destination is not in sync; skip the -DisableOnSource tail so the job is not left disabled on both sides + continue + } + } + # Nothing to create; fall through so -DisableOnSource still applies + $skipCreate = $true + } else { + # Definition differs and source is newer (or the timestamps tie): source wins + if ($Pscmdlet.ShouldProcess($destinstance, "Job $destJobName differs ($changedText) and source is not older (source: $sourceDate UTC, dest: $destDate UTC). Dropping and recreating.")) { + try { + Write-Message -Message "Job $destJobName differs from source ($changedText). Dropping and recreating." -Level Verbose + # Before dropping, save which alerts reference this job + $splatAlertsForJob = @{ + SqlInstance = $destServer + Database = "msdb" + Query = "SELECT name FROM dbo.sysalerts WHERE job_id = (SELECT job_id FROM dbo.sysjobs WHERE name = @jobName)" + SqlParameter = @{ jobName = $destJobName } + } + $alertsReferencingJob = (Invoke-DbaQuery @splatAlertsForJob).name + Write-Message -Message "Found $($alertsReferencingJob.Count) alert(s) referencing job $destJobName" -Level Verbose + $destJob.Drop() + } catch { + $copyJobStatus.Status = "Failed" + $copyJobStatus.Notes = (Get-ErrorMessage -Record $_).Message + $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject + Write-Message -Level Verbose -Message "Issue dropping job $jobName on $destinstance | $PSItem" + continue } - $alertsReferencingJob = (Invoke-DbaQuery @splatAlertsForJob).name - Write-Message -Message "Found $($alertsReferencingJob.Count) alert(s) referencing job $destJobName" -Level Verbose - $destServer.JobServer.Jobs[$destJobName].Drop() - } catch { - $copyJobStatus.Status = "Failed" - $copyJobStatus.Notes = (Get-ErrorMessage -Record $_).Message - $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject - Write-Message -Level Verbose -Message "Issue dropping job $jobName on $destinstance | $PSItem" - continue } } - } elseif ($sourceDate -eq $destDate) { - # Dates are equal, skip - if ($Pscmdlet.ShouldProcess($destinstance, "Job $jobName has same modification date. Skipping.")) { - $copyJobStatus.Status = "Skipped" - $copyJobStatus.Notes = "Job has same modification date on source and destination" - $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject - Write-Message -Level Verbose -Message "Job $jobName has same modification date ($sourceDate). Skipping." - } - continue - } else { - # Destination is newer, skip with warning - if ($Pscmdlet.ShouldProcess($destinstance, "Job $jobName is newer on destination. Skipping.")) { - $copyJobStatus.Status = "Skipped" - $copyJobStatus.Notes = "Destination job is newer than source (dest: $destDate, source: $sourceDate)" - $copyJobStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject - Write-Message -Level Warning -Message "Job $jobName is newer on destination ($destDate) than source ($sourceDate). Skipping." - } - continue } } catch { - Write-Message -Level Warning -Message "Error comparing dates for job $jobName | $PSItem" + Write-Message -Level Warning -Message "Error comparing job definitions for $jobName | $PSItem" if ($force -eq $false) { if ($Pscmdlet.ShouldProcess($destinstance, "Job $jobName exists at destination. Use -Force to drop and migrate.")) { $copyJobStatus.Status = "Skipped" @@ -428,7 +516,7 @@ function Copy-DbaAgentJob { } } - if ($Pscmdlet.ShouldProcess($destinstance, "Creating Job $destJobName")) { + if (-not $skipCreate -and $Pscmdlet.ShouldProcess($destinstance, "Creating Job $destJobName")) { try { Write-Message -Message "Copying Job $jobName as $destJobName" -Level Verbose $sql = $serverJob.Script() | Out-String @@ -484,7 +572,8 @@ function Copy-DbaAgentJob { } } - if ($DisableOnDestination) { + # Already reflected in the comparison when the job was not recreated + if ($DisableOnDestination -and -not $skipCreate) { if ($Pscmdlet.ShouldProcess($destinstance, "Disabling $destJobName")) { Write-Message -Message "Disabling $destJobName on $destinstance" -Level Verbose $destServer.JobServer.Jobs[$destJobName].IsEnabled = $False @@ -502,4 +591,4 @@ function Copy-DbaAgentJob { } } } -} \ No newline at end of file +} diff --git a/tests/Copy-DbaAgentJob.Tests.ps1 b/tests/Copy-DbaAgentJob.Tests.ps1 index cd9bdbf98ae..e8fc895f637 100644 --- a/tests/Copy-DbaAgentJob.Tests.ps1 +++ b/tests/Copy-DbaAgentJob.Tests.ps1 @@ -122,12 +122,25 @@ Describe $CommandName -Tag IntegrationTests { BeforeAll { $PSDefaultParameterValues["*-Dba*:EnableException"] = $true - # Create a test job on both source and destination with same modification date $testJobModified = "dbatoolsci_copyjob_modified" + $testScheduleName = "dbatoolsci_copyjob_schedule" + + # Source job with a schedule, so schedule-only changes can be exercised $null = New-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1 -Job $testJobModified + $splatSchedule = @{ + SqlInstance = $TestConfig.InstanceCopy1 + Job = $testJobModified + Schedule = $testScheduleName + FrequencyType = "Daily" + FrequencyInterval = "EveryDay" + StartDate = (Get-Date).ToString("yyyyMMdd") + StartTime = "080000" + Force = $true + } + $null = New-DbaAgentSchedule @splatSchedule Start-Sleep -Seconds 2 - # Copy to destination first time + # Initial copy: destination now carries a later date_modified than the source $splatInitialCopy = @{ Source = $TestConfig.InstanceCopy1 Destination = $TestConfig.InstanceCopy2 @@ -135,57 +148,178 @@ Describe $CommandName -Tag IntegrationTests { } $null = Copy-DbaAgentJob @splatInitialCopy - # Ensure both jobs have the exact same date_modified by setting destination to match source - $escapedJobName = $testJobModified.Replace("'", "''") - $sourceDate = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy1 -Database msdb -Query "SELECT date_modified FROM dbo.sysjobs WHERE name = '$escapedJobName'" | Select-Object -ExpandProperty date_modified - $updateQuery = "UPDATE msdb.dbo.sysjobs SET date_modified = '$($sourceDate.ToString("yyyy-MM-dd HH:mm:ss.fff"))' WHERE name = '$escapedJobName'" - $null = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy2 -Query $updateQuery + # Read msdb directly so assertions don't depend on cached SMO objects + $queryJobRow = "SELECT job_id, enabled, date_modified FROM dbo.sysjobs WHERE name = @jobName" + $queryScheduleTime = " +SELECT s.active_start_time +FROM dbo.sysjobs AS j +INNER JOIN dbo.sysjobschedules AS js ON js.job_id = j.job_id +INNER JOIN dbo.sysschedules AS s ON s.schedule_id = js.schedule_id +WHERE j.name = @jobName AND s.name = @scheduleName" $PSDefaultParameterValues.Remove("*-Dba*:EnableException") } AfterAll { $PSDefaultParameterValues["*-Dba*:EnableException"] = $true - $null = Remove-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1 -Job dbatoolsci_copyjob_modified -ErrorAction SilentlyContinue - $null = Remove-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy2 -Job dbatoolsci_copyjob_modified -ErrorAction SilentlyContinue + # Pipe from Get so a job that never got created (failed setup) doesn't turn cleanup into a second failure + $null = Get-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1, $TestConfig.InstanceCopy2 -Job dbatoolsci_copyjob_modified | Remove-DbaAgentJob $PSDefaultParameterValues.Remove("*-Dba*:EnableException") } - It "skips job when dates are equal" { + It "skips job when definitions are identical even though date_modified differs" { + $sourceRow = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy1 -Database msdb -Query $queryJobRow -SqlParameter @{ jobName = $testJobModified } + $destRow = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy2 -Database msdb -Query $queryJobRow -SqlParameter @{ jobName = $testJobModified } + $sourceRow.date_modified | Should -Not -Be $destRow.date_modified + $splatUseModified = @{ Source = $TestConfig.InstanceCopy1 Destination = $TestConfig.InstanceCopy2 - Job = "dbatoolsci_copyjob_modified" + Job = $testJobModified UseLastModified = $true } $result = Copy-DbaAgentJob @splatUseModified - $result.Name | Should -Be "dbatoolsci_copyjob_modified" + $result.Name | Should -Be $testJobModified $result.Status | Should -Be "Skipped" - $result.Notes | Should -BeLike "*same modification date*" + $result.Notes | Should -BeLike "*identical*" } It "updates job when source is newer" { $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $null = Set-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1 -Job $testJobModified -Description "Modified description" + Start-Sleep -Seconds 2 + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") - # Modify the source job to make it newer - $sourceJob = Get-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1 -Job "dbatoolsci_copyjob_modified" - $sourceJob.Description = "Modified description" - $sourceJob.Alter() + $splatUseModified = @{ + Source = $TestConfig.InstanceCopy1 + Destination = $TestConfig.InstanceCopy2 + Job = $testJobModified + UseLastModified = $true + } + $result = Copy-DbaAgentJob @splatUseModified + + $result.Name | Should -Be $testJobModified + $result.Status | Should -Be "Successful" + (Get-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy2 -Job $testJobModified).Description | Should -Be "Modified description" + } + + It "aligns enabled state in place without recreating the job" { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $destJobIdBefore = (Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy2 -Database msdb -Query $queryJobRow -SqlParameter @{ jobName = $testJobModified }).job_id + $null = Set-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1 -Job $testJobModified -Disabled Start-Sleep -Seconds 2 + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + $splatUseModified = @{ + Source = $TestConfig.InstanceCopy1 + Destination = $TestConfig.InstanceCopy2 + Job = $testJobModified + UseLastModified = $true + } + $result = Copy-DbaAgentJob @splatUseModified + $result.Status | Should -Be "Successful" + $result.Notes | Should -BeLike "*in place*" + $destRow = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy2 -Database msdb -Query $queryJobRow -SqlParameter @{ jobName = $testJobModified } + $destRow.enabled | Should -Be 0 + $destRow.job_id | Should -Be $destJobIdBefore + } + + It "recreates job when only the schedule changed on the source" { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + # sp_update_schedule touches sysschedules.date_modified only, never sysjobs.date_modified + $splatUpdateSchedule = @{ + SqlInstance = $TestConfig.InstanceCopy1 + Database = "msdb" + Query = "EXEC dbo.sp_update_schedule @name = @scheduleName, @active_start_time = 100000" + SqlParameter = @{ scheduleName = $testScheduleName } + } + $null = Invoke-DbaQuery @splatUpdateSchedule + Start-Sleep -Seconds 2 $PSDefaultParameterValues.Remove("*-Dba*:EnableException") $splatUseModified = @{ Source = $TestConfig.InstanceCopy1 Destination = $TestConfig.InstanceCopy2 - Job = "dbatoolsci_copyjob_modified" + Job = $testJobModified UseLastModified = $true } $result = Copy-DbaAgentJob @splatUseModified - $result.Name | Should -Be "dbatoolsci_copyjob_modified" $result.Status | Should -Be "Successful" + $splatCheck = @{ + SqlInstance = $TestConfig.InstanceCopy2 + Database = "msdb" + Query = $queryScheduleTime + SqlParameter = @{ jobName = $testJobModified; scheduleName = $testScheduleName } + } + (Invoke-DbaQuery @splatCheck).active_start_time | Should -Be 100000 + } + + It "does not churn a job kept disabled with -DisableOnDestination" { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $null = Set-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy1 -Job $testJobModified -Enabled + $splatForceDisabled = @{ + Source = $TestConfig.InstanceCopy1 + Destination = $TestConfig.InstanceCopy2 + Job = $testJobModified + Force = $true + DisableOnDestination = $true + } + $null = Copy-DbaAgentJob @splatForceDisabled + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + $splatUseModified = @{ + Source = $TestConfig.InstanceCopy1 + Destination = $TestConfig.InstanceCopy2 + Job = $testJobModified + UseLastModified = $true + DisableOnDestination = $true + } + $result = Copy-DbaAgentJob @splatUseModified + + $result.Status | Should -Be "Skipped" + $result.Notes | Should -BeLike "*identical*" + } + + It "applies -DisableOnSource when the job is identical on the destination" { + $splatUseModified = @{ + Source = $TestConfig.InstanceCopy1 + Destination = $TestConfig.InstanceCopy2 + Job = $testJobModified + UseLastModified = $true + DisableOnDestination = $true + DisableOnSource = $true + } + $result = Copy-DbaAgentJob @splatUseModified + + $result.Status | Should -Be "Skipped" + $result.Notes | Should -BeLike "*identical*" + $sourceRow = Invoke-DbaQuery -SqlInstance $TestConfig.InstanceCopy1 -Database msdb -Query $queryJobRow -SqlParameter @{ jobName = $testJobModified } + $sourceRow.enabled | Should -Be 0 + } + + It "skips job when definition differs but destination is newer" { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + # Guarantee the destination edit lands after the source's last change + Start-Sleep -Seconds 2 + $null = Set-DbaAgentJob -SqlInstance $TestConfig.InstanceCopy2 -Job $testJobModified -Description "Changed on destination" + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + $splatUseModified = @{ + Source = $TestConfig.InstanceCopy1 + Destination = $TestConfig.InstanceCopy2 + Job = $testJobModified + UseLastModified = $true + WarningVariable = "warn" + WarningAction = "SilentlyContinue" + } + $result = Copy-DbaAgentJob @splatUseModified + + $result.Status | Should -Be "Skipped" + $result.Notes | Should -BeLike "*destination job is newer than source*" + ($warn -join " ") | Should -Match "newer on destination" } } @@ -318,4 +452,4 @@ WHERE a.name = '$testAlertName' $renamedJob | Should -Not -BeNullOrEmpty } } -} \ No newline at end of file +}