Skip to content

Commit 552bf89

Browse files
Copy-DbaAgentJobStep - Add new command to synchronize job steps without destroying history (#10058)
1 parent d248aa6 commit 552bf89

5 files changed

Lines changed: 814 additions & 0 deletions

File tree

dbatools.psd1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
'Copy-DbaAgentAlert',
9494
'Copy-DbaAgentJob',
9595
'Copy-DbaAgentJobCategory',
96+
'Copy-DbaAgentJobStep',
9697
'Copy-DbaAgentOperator',
9798
'Copy-DbaAgentProxy',
9899
'Copy-DbaAgentSchedule',

dbatools.psm1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ if ($PSVersionTable.PSVersion.Major -lt 5) {
413413
'Copy-DbaStartupProcedure',
414414
'Get-DbaDbDetachedFileInfo',
415415
'Copy-DbaAgentJobCategory',
416+
'Copy-DbaAgentJobStep',
416417
'Get-DbaLinkedServerLogin',
417418
'Test-DbaPath',
418419
'Export-DbaLogin',

public/Copy-DbaAgentJobStep.ps1

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
function Copy-DbaAgentJobStep {
2+
<#
3+
.SYNOPSIS
4+
Copies job steps from one SQL Server Agent job to another, preserving job history by synchronizing steps without dropping the job itself.
5+
6+
.DESCRIPTION
7+
Synchronizes SQL Server Agent job steps between instances by copying step definitions from source jobs to destination jobs. Unlike Copy-DbaAgentJob with -Force, this command preserves job execution history because it only drops and recreates individual steps rather than the entire job. This is essential for maintaining historical job execution data in Always On Availability Group scenarios, disaster recovery environments, or when deploying step modifications across multiple servers.
8+
9+
The function removes all existing steps from the destination job before copying source steps, ensuring a clean synchronization. Job metadata like ownership, schedules, and alerts remain unchanged on the destination.
10+
11+
.PARAMETER Source
12+
Source SQL Server instance containing the jobs with steps to copy. You must have sysadmin access and server version must be SQL Server 2000 or higher.
13+
Use this when copying job steps from a specific instance rather than piping job objects with InputObject.
14+
15+
.PARAMETER SourceSqlCredential
16+
Alternative credentials for connecting to the source SQL Server instance. Accepts PowerShell credentials (Get-Credential).
17+
Use this when the source server requires different authentication than your current Windows session, such as SQL authentication or cross-domain scenarios.
18+
Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated are all supported.
19+
20+
.PARAMETER Destination
21+
Destination SQL Server instance(s) where job steps will be synchronized. You must have sysadmin access and the server must be SQL Server 2000 or higher.
22+
Supports multiple destinations to copy job steps to multiple servers simultaneously, such as syncing all AG replicas or DR servers.
23+
24+
.PARAMETER DestinationSqlCredential
25+
Alternative credentials for connecting to the destination SQL Server instance. Accepts PowerShell credentials (Get-Credential).
26+
Use this when the destination server requires different authentication than your current Windows session, such as SQL authentication or cross-domain scenarios.
27+
Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated are all supported.
28+
29+
.PARAMETER Job
30+
Specifies which SQL Agent jobs to process by name. Accepts wildcards and multiple job names.
31+
Use this to synchronize steps for specific jobs, such as copying modified steps from a primary AG replica to secondary replicas.
32+
If unspecified, all jobs will be processed.
33+
34+
.PARAMETER ExcludeJob
35+
Specifies which SQL Agent jobs to skip during the copy operation. Accepts wildcards and multiple job names.
36+
Use this to exclude specific jobs from bulk operations, such as skipping environment-specific jobs that shouldn't be synchronized.
37+
38+
.PARAMETER Step
39+
Specifies which job steps to copy by name. If not specified, all steps are copied.
40+
Use this to synchronize specific steps rather than all steps from a job.
41+
42+
.PARAMETER InputObject
43+
Accepts SQL Agent job objects from the pipeline, typically from Get-DbaAgentJob.
44+
Use this to copy steps for pre-filtered jobs or when combining with other job management cmdlets for complex workflows.
45+
46+
.PARAMETER WhatIf
47+
If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
48+
49+
.PARAMETER Confirm
50+
If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
51+
52+
.PARAMETER EnableException
53+
By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message.
54+
This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting.
55+
Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch.
56+
57+
.NOTES
58+
Tags: Migration, Agent, Job
59+
Author: the dbatools team + Claude
60+
61+
Website: https://dbatools.io
62+
Copyright: (c) 2018 by dbatools, licensed under MIT
63+
License: MIT https://opensource.org/licenses/MIT
64+
65+
.LINK
66+
https://dbatools.io/Copy-DbaAgentJobStep
67+
68+
.EXAMPLE
69+
PS C:\> Copy-DbaAgentJobStep -Source PrimaryAG -Destination SecondaryAG1, SecondaryAG2 -Job "MaintenanceJob"
70+
71+
Copies all job steps from the "MaintenanceJob" on PrimaryAG to the same job on SecondaryAG1 and SecondaryAG2, preserving job history on the destination servers.
72+
73+
.EXAMPLE
74+
PS C:\> Get-DbaAgentJob -SqlInstance PrimaryAG -Job "BackupJob" | Copy-DbaAgentJobStep -Destination SecondaryAG1
75+
76+
Retrieves the BackupJob from PrimaryAG and synchronizes its steps to the same job on SecondaryAG1 using pipeline input.
77+
78+
.EXAMPLE
79+
PS C:\> Copy-DbaAgentJobStep -Source sqlserver2014a -Destination sqlcluster -Job "DataETL" -SourceSqlCredential $cred
80+
81+
Copies job steps for the "DataETL" job from sqlserver2014a to sqlcluster, using SQL credentials for the source server and Windows credentials for the destination.
82+
83+
.EXAMPLE
84+
PS C:\> Copy-DbaAgentJobStep -Source Primary -Destination Replica1, Replica2, Replica3
85+
86+
Synchronizes all job steps from Primary to multiple AG replicas, ensuring all replicas have identical job step definitions while preserving their individual job execution histories.
87+
#>
88+
[CmdletBinding(DefaultParameterSetName = "Default", SupportsShouldProcess, ConfirmImpact = "Medium")]
89+
param (
90+
[DbaInstanceParameter]$Source,
91+
[PSCredential]$SourceSqlCredential,
92+
[parameter(Mandatory)]
93+
[DbaInstanceParameter[]]$Destination,
94+
[PSCredential]$DestinationSqlCredential,
95+
[object[]]$Job,
96+
[object[]]$ExcludeJob,
97+
[string[]]$Step,
98+
[parameter(ValueFromPipeline)]
99+
[Microsoft.SqlServer.Management.Smo.Agent.Job[]]$InputObject,
100+
[switch]$EnableException
101+
)
102+
begin {
103+
if ($Source) {
104+
try {
105+
$splatGetJob = @{
106+
SqlInstance = $Source
107+
SqlCredential = $SourceSqlCredential
108+
}
109+
if (Test-Bound "Job") {
110+
$splatGetJob["Job"] = $Job
111+
}
112+
if (Test-Bound "ExcludeJob") {
113+
$splatGetJob["ExcludeJob"] = $ExcludeJob
114+
}
115+
$InputObject = Get-DbaAgentJob @splatGetJob
116+
} catch {
117+
Stop-Function -Message "Error occurred while establishing connection to $Source" -Category ConnectionError -ErrorRecord $_ -Target $Source
118+
return
119+
}
120+
}
121+
}
122+
process {
123+
if (Test-FunctionInterrupt) { return }
124+
foreach ($destinstance in $Destination) {
125+
try {
126+
$destServer = Connect-DbaInstance -SqlInstance $destinstance -SqlCredential $DestinationSqlCredential
127+
} catch {
128+
Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $destinstance -Continue
129+
}
130+
$destJobs = $destServer.JobServer.Jobs
131+
132+
foreach ($sourceJob in $InputObject) {
133+
$jobName = $sourceJob.Name
134+
$sourceserver = $sourceJob.Parent.Parent
135+
136+
$copyJobStepStatus = [PSCustomObject]@{
137+
SourceServer = $sourceserver.Name
138+
DestinationServer = $destServer.Name
139+
Name = $jobName
140+
Type = "Agent Job Steps"
141+
Status = $null
142+
Notes = $null
143+
DateTime = [DbaDateTime](Get-Date)
144+
}
145+
146+
if ((Test-Bound "Job") -and $jobName -notin $Job) {
147+
Write-Message -Level Verbose -Message "Job [$jobName] filtered. Skipping."
148+
continue
149+
}
150+
if ((Test-Bound "ExcludeJob") -and $jobName -in $ExcludeJob) {
151+
Write-Message -Level Verbose -Message "Job [$jobName] excluded. Skipping."
152+
continue
153+
}
154+
Write-Message -Message "Working on job: $jobName" -Level Verbose
155+
156+
if ($destJobs.name -notcontains $sourceJob.name) {
157+
if ($Pscmdlet.ShouldProcess($destinstance, "Job $jobName does not exist on destination. Skipping step synchronization.")) {
158+
$copyJobStepStatus.Status = "Skipped"
159+
$copyJobStepStatus.Notes = "Job does not exist on destination. Use Copy-DbaAgentJob to create it first."
160+
$copyJobStepStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject
161+
Write-Message -Level Warning -Message "Job $jobName does not exist on destination $destinstance. Use Copy-DbaAgentJob to create it first."
162+
}
163+
continue
164+
}
165+
166+
# Filter source steps if Step parameter is specified
167+
$sourceSteps = $sourceJob.JobSteps
168+
if (Test-Bound "Step") {
169+
$sourceSteps = $sourceSteps | Where-Object Name -in $Step
170+
if (-not $sourceSteps) {
171+
Write-Message -Level Warning -Message "No matching steps found in job $jobName for specified step names: $($Step -join ', ')"
172+
continue
173+
}
174+
}
175+
176+
if ($Pscmdlet.ShouldProcess($destinstance, "Synchronizing steps for job $jobName")) {
177+
try {
178+
$destJob = $destServer.JobServer.Jobs[$jobName]
179+
180+
# Remove existing steps - copy to array first to avoid collection modification during enumeration
181+
$stepsToRemove = @($destJob.JobSteps | ForEach-Object { $_ })
182+
if (Test-Bound "Step") {
183+
$stepsToRemove = $stepsToRemove | Where-Object Name -in $Step
184+
}
185+
186+
Write-Message -Message "Removing $($stepsToRemove.Count) existing step(s) from $jobName on $destinstance" -Level Verbose
187+
foreach ($stepToRemove in $stepsToRemove) {
188+
Write-Message -Message "Removing step $($stepToRemove.Name) from $jobName on $destinstance" -Level Verbose
189+
$stepToRemove.Drop()
190+
}
191+
$destJob.JobSteps.Refresh()
192+
193+
Write-Message -Message "Copying $($sourceSteps.Count) step(s) from $jobName to $destinstance" -Level Verbose
194+
foreach ($sourceStep in $sourceSteps) {
195+
Write-Message -Message "Creating step $($sourceStep.Name) in $jobName on $destinstance" -Level Verbose
196+
$sql = $sourceStep.Script() | Out-String
197+
# Replace @job_id with @job_name since the destination job has a different GUID
198+
$sql = $sql -replace "@job_id=N'[0-9a-fA-F-]+'", "@job_name=N'$($jobName -replace "'", "''")'"
199+
Write-Message -Message $sql -Level Debug
200+
$destServer.Query($sql)
201+
}
202+
203+
$destJob.JobSteps.Refresh()
204+
$copyJobStepStatus.Status = "Successful"
205+
$copyJobStepStatus.Notes = "Synchronized $($sourceSteps.Count) job step(s)"
206+
$copyJobStepStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject
207+
} catch {
208+
$copyJobStepStatus.Status = "Failed"
209+
$copyJobStepStatus.Notes = (Get-ErrorMessage -Record $_)
210+
$copyJobStepStatus | Select-DefaultView -Property DateTime, SourceServer, DestinationServer, Name, Type, Status, Notes -TypeName MigrationObject
211+
Stop-Function -Message "Failed to synchronize steps for job $jobName on $destinstance" -ErrorRecord $_ -Target $destinstance -Continue
212+
}
213+
}
214+
}
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)