From 2f5e0cb70cdd471452a73f69c4faaeac30ba2ff6 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 3 Aug 2026 17:55:46 +0100 Subject: [PATCH] Add SQL Agent Job Deployment Tool and CI integration Added a C# Windows Forms app, SqlServerAgentJobDeploymentTool, for deploying SQL Server Agent jobs from a JSON manifest. Includes project files, core source code, sample manifests, and documentation. Updated createrelease.yml to build, package, and deploy the tool as an artifact in CI/CD workflows for staging and production. Tool supports CLI/UI, manifest validation, dry-run, and deployment. --- .github/workflows/createrelease.yml | 89 +- .../ApplicationConfiguration.cs | 10 + SqlServerAgentJobDeploymentTool/MainForm.cs | 804 ++++++++++++++++++ SqlServerAgentJobDeploymentTool/Program.cs | 293 +++++++ SqlServerAgentJobDeploymentTool/README.md | 42 + .../SqlAgentDeploymentService.cs | 190 +++++ .../SqlAgentModels.cs | 125 +++ .../SqlServerAgentJobDeploymentTool.csproj | 28 + .../SqlServerAgentJobDeploymentTool.sln | 21 + .../sql-agent-jobs.sample.json | 52 ++ ...nprocessor-readmodel-transaction-jobs.json | 39 + 11 files changed, 1691 insertions(+), 2 deletions(-) create mode 100644 SqlServerAgentJobDeploymentTool/ApplicationConfiguration.cs create mode 100644 SqlServerAgentJobDeploymentTool/MainForm.cs create mode 100644 SqlServerAgentJobDeploymentTool/Program.cs create mode 100644 SqlServerAgentJobDeploymentTool/README.md create mode 100644 SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs create mode 100644 SqlServerAgentJobDeploymentTool/SqlAgentModels.cs create mode 100644 SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.csproj create mode 100644 SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.sln create mode 100644 SqlServerAgentJobDeploymentTool/sql-agent-jobs.sample.json create mode 100644 SqlServerAgentJobDeploymentTool/transactionprocessor-readmodel-transaction-jobs.json diff --git a/.github/workflows/createrelease.yml b/.github/workflows/createrelease.yml index c7a75b2..7cae21d 100644 --- a/.github/workflows/createrelease.yml +++ b/.github/workflows/createrelease.yml @@ -69,10 +69,45 @@ jobs: with: name: scheduledtasks path: ScheduledTasks + + buildsqlagentjobdeploymenttool: + name: "Build SQL Agent Job Deployment Tool" + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install NET 10 + uses: actions/setup-dotnet@v4.0.1 + with: + dotnet-version: '10.0.x' + + - name: Publish SQL Agent Job Deployment Tool + shell: pwsh + run: | + dotnet publish SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + --output SqlServerAgentJobDeploymentTool/publishOutput + + - name: Build Release Package (SQL Agent Job Deployment Tool) + shell: pwsh + run: | + Compress-Archive ` + -Path SqlServerAgentJobDeploymentTool/publishOutput/* ` + -DestinationPath SqlServerAgentJobDeploymentTool/sqlagentjobdeploymenttool.zip ` + -Force + + - name: Upload the artifact (SQL Agent Job Deployment Tool) + uses: actions/upload-artifact@v4.4.0 + with: + name: sqlagentjobdeploymenttool + path: SqlServerAgentJobDeploymentTool/sqlagentjobdeploymenttool.zip deploystaging: runs-on: [stagingserver, windows] - needs: build + needs: [build, buildsqlagentjobdeploymenttool] environment: staging name: "Deploy to Staging" @@ -119,6 +154,31 @@ jobs: throw "Failed to register scheduled task [$taskName] from [$($_.FullName)]" } } + + - name: Download SQL Agent Job Deployment Tool + uses: actions/download-artifact@v4.1.8 + with: + name: sqlagentjobdeploymenttool + path: ${{ runner.temp }}\sqlagentjobdeploymenttool + + - name: Deploy SQL Agent Job + shell: powershell + env: + SQL_AGENT_JOB_CONNECTION_STRING: ${{ secrets.SQL_AGENT_JOB_CONNECTION_STRING }} + SQL_AGENT_JOB_DATABASE_NAME: ${{ secrets.SQL_AGENT_JOB_DATABASE_NAME }} + run: | + $toolRoot = Join-Path $env:RUNNER_TEMP 'sqlagentjobdeploymenttool' + Expand-Archive -Path (Join-Path $toolRoot 'sqlagentjobdeploymenttool.zip') -DestinationPath $toolRoot -Force + $toolExe = Join-Path $toolRoot 'SqlServerAgentJobDeploymentTool.exe' + + & $toolExe ` + --manifest (Join-Path $toolRoot 'transactionprocessor-readmodel-transaction-jobs.json') ` + --connection-string $env:SQL_AGENT_JOB_CONNECTION_STRING ` + --database-name $env:SQL_AGENT_JOB_DATABASE_NAME + + if ($LASTEXITCODE -ne 0) { + throw "SQL Agent job deployment failed." + } - name: Download the artifact (Merchant Pos) uses: actions/download-artifact@v4.1.8 @@ -174,7 +234,7 @@ jobs: deployproduction: runs-on: [productionserver, windows] - needs: [build, deploystaging] + needs: [build, buildsqlagentjobdeploymenttool, deploystaging] environment: production name: "Deploy to Production" @@ -221,6 +281,31 @@ jobs: throw "Failed to register scheduled task [$taskName] from [$($_.FullName)]" } } + + - name: Download SQL Agent Job Deployment Tool + uses: actions/download-artifact@v4.1.8 + with: + name: sqlagentjobdeploymenttool + path: ${{ runner.temp }}\sqlagentjobdeploymenttool + + - name: Deploy SQL Agent Job + shell: powershell + env: + SQL_AGENT_JOB_CONNECTION_STRING: ${{ secrets.SQL_AGENT_JOB_CONNECTION_STRING }} + SQL_AGENT_JOB_DATABASE_NAME: ${{ secrets.SQL_AGENT_JOB_DATABASE_NAME }} + run: | + $toolRoot = Join-Path $env:RUNNER_TEMP 'sqlagentjobdeploymenttool' + Expand-Archive -Path (Join-Path $toolRoot 'sqlagentjobdeploymenttool.zip') -DestinationPath $toolRoot -Force + $toolExe = Join-Path $toolRoot 'SqlServerAgentJobDeploymentTool.exe' + + & $toolExe ` + --manifest (Join-Path $toolRoot 'transactionprocessor-readmodel-transaction-jobs.json') ` + --connection-string $env:SQL_AGENT_JOB_CONNECTION_STRING ` + --database-name $env:SQL_AGENT_JOB_DATABASE_NAME + + if ($LASTEXITCODE -ne 0) { + throw "SQL Agent job deployment failed." + } - name: Remove existing Windows service (Merchant Pos) run: | diff --git a/SqlServerAgentJobDeploymentTool/ApplicationConfiguration.cs b/SqlServerAgentJobDeploymentTool/ApplicationConfiguration.cs new file mode 100644 index 0000000..e656819 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/ApplicationConfiguration.cs @@ -0,0 +1,10 @@ +namespace SqlServerAgentJobDeploymentTool; + +internal static class ApplicationConfiguration +{ + public static void Initialize() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + } +} diff --git a/SqlServerAgentJobDeploymentTool/MainForm.cs b/SqlServerAgentJobDeploymentTool/MainForm.cs new file mode 100644 index 0000000..0799de1 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/MainForm.cs @@ -0,0 +1,804 @@ +namespace SqlServerAgentJobDeploymentTool; + +using System.Drawing; +using System.Text; +using Microsoft.Data.SqlClient; + +internal sealed class MainForm : Form +{ + private readonly TextBox _manifestPathTextBox; + private readonly TextBox _serverTextBox; + private readonly TextBox _connectionDatabaseTextBox; + private readonly TextBox _databaseNameTextBox; + private readonly CheckBox _useSslCheckBox; + private readonly ComboBox _authenticationTypeComboBox; + private readonly TextBox _usernameTextBox; + private readonly TextBox _passwordTextBox; + private readonly Label _usernameLabel; + private readonly Label _passwordLabel; + private readonly TextBox _manifestTextBox; + private readonly TextBox _outputTextBox; + private readonly Button _loadManifestButton; + private readonly Button _browseButton; + private readonly Button _saveButton; + private readonly Button _saveAsButton; + private readonly Button _formatButton; + private readonly Button _dryRunButton; + private readonly Button _deployButton; + private readonly ToolStripStatusLabel _statusLabel; + private readonly TextBox _validationSummaryTextBox; + + public MainForm() + { + Text = "SQL Agent Job Deployment Tool"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(1280, 820); + Font = new Font("Segoe UI", 9F); + BackColor = Color.FromArgb(245, 247, 250); + + StatusStrip statusStrip = new(); + _statusLabel = new ToolStripStatusLabel("Ready"); + statusStrip.Items.Add(_statusLabel); + + Panel headerPanel = new() + { + Dock = DockStyle.Top, + Height = 72, + BackColor = Color.White, + Padding = new Padding(18, 14, 18, 14) + }; + + TableLayoutPanel headerLayout = new() + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + headerLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + headerLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + + headerLayout.Controls.Add(new Label + { + Text = "SQL Agent Job Deployment Tool", + AutoSize = true, + Font = new Font(Font, FontStyle.Bold), + ForeColor = Color.FromArgb(31, 41, 55) + }, 0, 0); + + headerLayout.Controls.Add(new Label + { + Text = "Load a manifest, review it, then deploy SQL Server Agent jobs with a target server and database override.", + AutoSize = true, + ForeColor = Color.FromArgb(75, 85, 99) + }, 0, 1); + + headerPanel.Controls.Add(headerLayout); + + SplitContainer split = new() + { + Dock = DockStyle.Fill, + SplitterDistance = 820, + FixedPanel = FixedPanel.Panel2 + }; + + Label manifestPathLabel = new() { Text = "Manifest", AutoSize = true }; + _manifestPathTextBox = new TextBox { Dock = DockStyle.Fill }; + _browseButton = new Button { Text = "Browse..." }; + _loadManifestButton = new Button { Text = "Load" }; + _saveButton = new Button { Text = "Save" }; + _saveAsButton = new Button { Text = "Save As" }; + _formatButton = new Button { Text = "Format JSON" }; + + _browseButton.Click += BrowseButton_Click; + _loadManifestButton.Click += LoadManifestButton_Click; + _saveButton.Click += SaveManifestButton_Click; + _saveAsButton.Click += SaveAsButton_Click; + _formatButton.Click += FormatButton_Click; + + TableLayoutPanel manifestPathRow = CreateRow(_manifestPathTextBox, _browseButton, _loadManifestButton, _saveButton, _saveAsButton, _formatButton); + + Panel connectionGroup = new() + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Dock = DockStyle.Top, + Margin = new Padding(0), + Padding = new Padding(0) + }; + + Label connectionTitle = new() + { + Text = "Connection settings", + AutoSize = true, + Font = new Font(Font, FontStyle.Bold), + Margin = new Padding(0, 0, 0, 2), + Dock = DockStyle.Top + }; + + _serverTextBox = new TextBox { Dock = DockStyle.Fill, Text = "localhost", Margin = new Padding(0) }; + _connectionDatabaseTextBox = new TextBox { Dock = DockStyle.Fill, Text = "msdb", Margin = new Padding(0) }; + _useSslCheckBox = new CheckBox { Text = "Use SSL", AutoSize = true, Margin = new Padding(0) }; + _authenticationTypeComboBox = new ComboBox + { + Dock = DockStyle.Fill, + DropDownStyle = ComboBoxStyle.DropDownList, + Margin = new Padding(0) + }; + _authenticationTypeComboBox.Items.AddRange(["Windows", "SQL"]); + _authenticationTypeComboBox.SelectedIndex = 0; + _authenticationTypeComboBox.SelectedIndexChanged += AuthenticationTypeComboBox_SelectedIndexChanged; + + _usernameTextBox = new TextBox { Dock = DockStyle.Fill, Margin = new Padding(0) }; + _passwordTextBox = new TextBox { Dock = DockStyle.Fill, UseSystemPasswordChar = true, Margin = new Padding(0) }; + + TableLayoutPanel connectionLayout = new() + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Dock = DockStyle.Top, + ColumnCount = 4, + RowCount = 3, + Padding = new Padding(0) + }; + connectionLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + connectionLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + connectionLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + connectionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + connectionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); + connectionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + connectionLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); + connectionLayout.Controls.Add(CreateCompactLabel("Server"), 0, 0); + connectionLayout.Controls.Add(_serverTextBox, 1, 0); + connectionLayout.Controls.Add(CreateCompactLabel("Connection database"), 2, 0); + connectionLayout.Controls.Add(_connectionDatabaseTextBox, 3, 0); + connectionLayout.Controls.Add(CreateCompactLabel("Authentication"), 0, 1); + connectionLayout.Controls.Add(_authenticationTypeComboBox, 1, 1); + _useSslCheckBox.Text = "Encrypt connection"; + connectionLayout.Controls.Add(_useSslCheckBox, 2, 1); + connectionLayout.SetColumnSpan(_useSslCheckBox, 2); + _usernameLabel = CreateCompactLabel("Username"); + _passwordLabel = CreateCompactLabel("Password"); + connectionLayout.Controls.Add(_usernameLabel, 0, 2); + connectionLayout.Controls.Add(_usernameTextBox, 1, 2); + connectionLayout.Controls.Add(_passwordLabel, 2, 2); + connectionLayout.Controls.Add(_passwordTextBox, 3, 2); + + connectionGroup.Controls.Add(connectionLayout); + connectionGroup.Controls.Add(connectionTitle); + + _databaseNameTextBox = new TextBox { Dock = DockStyle.Fill }; + + TableLayoutPanel databaseRow = CreateLabeledPanel( + new Label { Text = "Step database override", AutoSize = true }, + CreateSingleRow(_databaseNameTextBox)); + + GroupBox manifestGroup = new() + { + Dock = DockStyle.Fill, + Text = "Manifest content" + }; + + _manifestTextBox = new TextBox + { + Dock = DockStyle.Fill, + Multiline = true, + ScrollBars = ScrollBars.Both, + WordWrap = false, + Font = new Font("Consolas", 9F) + }; + manifestGroup.Controls.Add(_manifestTextBox); + + GroupBox actionsGroup = new() + { + Dock = DockStyle.Right, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Text = "Actions", + Padding = new Padding(4, 8, 4, 4), + Width = 110 + }; + + FlowLayoutPanel buttons = new() + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + AutoSize = true, + Padding = new Padding(0), + WrapContents = false + }; + + _dryRunButton = new Button { Text = "Dry run", Width = 84, Margin = new Padding(0, 0, 0, 6) }; + _deployButton = new Button { Text = "Deploy", Width = 84, Margin = new Padding(0) }; + _dryRunButton.Click += async (_, _) => await RunDeploymentAsync(true); + _deployButton.Click += async (_, _) => await RunDeploymentAsync(false); + + buttons.Controls.Add(_dryRunButton); + buttons.Controls.Add(_deployButton); + + actionsGroup.Controls.Add(buttons); + + Panel leftPanel = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(10) + }; + + Panel manifestWorkArea = new() + { + Dock = DockStyle.Fill, + Margin = new Padding(0), + Padding = new Padding(0) + }; + manifestWorkArea.Controls.Add(manifestGroup); + manifestWorkArea.Controls.Add(actionsGroup); + + TableLayoutPanel leftLayout = new() + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 4 + }; + leftLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + leftLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + leftLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + leftLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + leftLayout.Controls.Add(CreateLabeledPanel(manifestPathLabel, manifestPathRow), 0, 0); + leftLayout.Controls.Add(connectionGroup, 0, 1); + leftLayout.Controls.Add(databaseRow, 0, 2); + leftLayout.Controls.Add(manifestWorkArea, 0, 3); + leftPanel.Controls.Add(leftLayout); + + GroupBox outputGroup = new() + { + Dock = DockStyle.Fill, + Text = "Output", + Padding = new Padding(4, 8, 4, 4) + }; + + _outputTextBox = new TextBox + { + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + WordWrap = false + }; + outputGroup.Controls.Add(_outputTextBox); + + Panel rightPanel = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(10) + }; + + TableLayoutPanel rightLayout = new() + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2 + }; + rightLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + rightLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + + GroupBox validationGroup = new() + { + Dock = DockStyle.Fill, + Text = "Validation summary", + Padding = new Padding(4, 8, 4, 4) + }; + + _validationSummaryTextBox = new TextBox + { + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + WordWrap = false + }; + validationGroup.Controls.Add(_validationSummaryTextBox); + + rightLayout.RowStyles.Clear(); + rightLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + rightLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + rightLayout.Controls.Clear(); + rightLayout.Controls.Add(validationGroup, 0, 0); + rightLayout.Controls.Add(outputGroup, 0, 1); + rightPanel.Controls.Add(rightLayout); + + split.Panel1.Controls.Add(leftPanel); + split.Panel2.Controls.Add(rightPanel); + + Controls.Add(split); + Controls.Add(headerPanel); + Controls.Add(statusStrip); + + Load += MainForm_Load; + _manifestTextBox.TextChanged += ManifestTextBox_TextChanged; + UpdateAuthenticationVisibility(); + } + + private async void MainForm_Load(object? sender, EventArgs e) + { + string samplePath = Path.Combine(AppContext.BaseDirectory, "transactionprocessor-readmodel-transaction-jobs.json"); + _manifestPathTextBox.Text = samplePath; + + if (File.Exists(samplePath)) + { + await LoadManifestFromPathAsync(samplePath); + } + else + { + AppendOutput($"Sample manifest not found at '{samplePath}'."); + } + } + + private async void BrowseButton_Click(object? sender, EventArgs e) + { + using OpenFileDialog dialog = new() + { + Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", + Title = "Select SQL Agent manifest" + }; + + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + _manifestPathTextBox.Text = dialog.FileName; + await LoadManifestFromPathAsync(dialog.FileName); + } + + private async void LoadManifestButton_Click(object? sender, EventArgs e) + { + await LoadManifestFromPathAsync(_manifestPathTextBox.Text); + } + + private async void SaveManifestButton_Click(object? sender, EventArgs e) + { + await SaveManifestAsync(false); + } + + private async void SaveAsButton_Click(object? sender, EventArgs e) + { + await SaveManifestAsync(true); + } + + private async Task LoadManifestFromPathAsync(string manifestPath) + { + try + { + string manifestText = await File.ReadAllTextAsync(manifestPath); + _manifestPathTextBox.Text = manifestPath; + _manifestTextBox.Text = DeploymentManifestLoader.Format(manifestText); + AppendOutput($"Loaded manifest from '{manifestPath}'."); + SetStatus($"Loaded {Path.GetFileName(manifestPath)}"); + UpdateValidationSummary(); + } + catch (Exception ex) + { + AppendOutput(ex.Message); + MessageBox.Show(this, ex.Message, "Load failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private async Task RunDeploymentAsync(bool dryRun) + { + SetBusyState(true); + + try + { + UpdateValidationSummary(); + DeploymentManifest manifest = DeploymentManifestLoader.Parse(_manifestTextBox.Text); + + string overrideDatabaseName = _databaseNameTextBox.Text.Trim(); + + ManifestValidator.Validate(manifest); + + if (dryRun) + { + AppendOutput("Dry run. Jobs that would be deployed:"); + foreach (JobDefinition job in manifest.Jobs) + { + AppendOutput($"- {job.Name}"); + } + + UpdateValidationSummary(manifest); + SetStatus("Dry run completed"); + return; + } + + string connectionString = BuildConnectionString(); + await using SqlConnection connection = new(connectionString); + await connection.OpenAsync(); + + AppendOutput("Connecting to SQL Server..."); + SqlAgentDeploymentService deployer = new(connection, new TextBoxWriter(_outputTextBox)); + await deployer.DeployAsync(manifest, overrideDatabaseName, CancellationToken.None); + AppendOutput("Deployment completed."); + UpdateValidationSummary(manifest); + SetStatus("Deployment completed"); + } + catch (Exception ex) + { + AppendOutput(ex.Message); + UpdateValidationSummary(); + SetStatus("Deployment failed"); + MessageBox.Show(this, ex.Message, "Deployment failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SetBusyState(false); + } + } + + private async Task SaveManifestAsync(bool saveAs) + { + try + { + string manifestPath = _manifestPathTextBox.Text.Trim(); + if (saveAs || string.IsNullOrWhiteSpace(manifestPath)) + { + using SaveFileDialog dialog = new() + { + Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*", + Title = "Save SQL Agent manifest", + FileName = string.IsNullOrWhiteSpace(manifestPath) ? "sql-agent-jobs.json" : Path.GetFileName(manifestPath) + }; + + if (dialog.ShowDialog(this) != DialogResult.OK) + { + return; + } + + manifestPath = dialog.FileName; + _manifestPathTextBox.Text = manifestPath; + } + + if (string.IsNullOrWhiteSpace(manifestPath)) + { + throw new InvalidOperationException("Select a manifest path before saving."); + } + + string formattedManifest = DeploymentManifestLoader.Format(_manifestTextBox.Text); + _manifestTextBox.Text = formattedManifest; + await File.WriteAllTextAsync(manifestPath, formattedManifest); + AppendOutput($"Saved manifest to '{manifestPath}'."); + SetStatus($"Saved {Path.GetFileName(manifestPath)}"); + UpdateValidationSummary(); + } + catch (Exception ex) + { + AppendOutput(ex.Message); + SetStatus("Save failed"); + MessageBox.Show(this, ex.Message, "Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void ManifestTextBox_TextChanged(object? sender, EventArgs e) + { + UpdateValidationSummary(); + } + + private void FormatButton_Click(object? sender, EventArgs e) + { + try + { + _manifestTextBox.Text = DeploymentManifestLoader.Format(_manifestTextBox.Text); + AppendOutput("Formatted manifest JSON."); + SetStatus("JSON formatted"); + } + catch (Exception ex) + { + AppendOutput(ex.Message); + MessageBox.Show(this, ex.Message, "Format failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void AppendOutput(string message) + { + if (_outputTextBox.InvokeRequired) + { + _outputTextBox.BeginInvoke(() => AppendOutput(message)); + return; + } + + _outputTextBox.AppendText($"{DateTime.Now:HH:mm:ss} {message}{Environment.NewLine}"); + } + + private void SetBusyState(bool busy) + { + _dryRunButton.Enabled = !busy; + _deployButton.Enabled = !busy; + _browseButton.Enabled = !busy; + _loadManifestButton.Enabled = !busy; + _saveButton.Enabled = !busy; + _saveAsButton.Enabled = !busy; + _formatButton.Enabled = !busy; + _manifestTextBox.ReadOnly = busy; + _databaseNameTextBox.ReadOnly = busy; + _serverTextBox.ReadOnly = busy; + _connectionDatabaseTextBox.ReadOnly = busy; + _useSslCheckBox.Enabled = !busy; + _authenticationTypeComboBox.Enabled = !busy; + _usernameTextBox.ReadOnly = busy; + _passwordTextBox.ReadOnly = busy; + } + + private void SetStatus(string message) + { + if (_statusLabel.Owner?.InvokeRequired == true) + { + _statusLabel.Owner.BeginInvoke(() => _statusLabel.Text = message); + return; + } + + _statusLabel.Text = message; + } + + private static TableLayoutPanel CreateLabeledPanel(Control label, Control content) + { + label.Margin = new Padding(0); + content.Margin = new Padding(0); + + TableLayoutPanel panel = new() + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 2, + Margin = new Padding(0), + Padding = new Padding(0) + }; + panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + panel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + panel.Controls.Add(label, 0, 0); + panel.Controls.Add(content, 0, 1); + return panel; + } + + private static Label CreateCompactLabel(string text) + { + return new Label + { + Text = text, + AutoSize = true, + Anchor = AnchorStyles.Left, + Margin = new Padding(0, 0, 6, 0) + }; + } + + private static TableLayoutPanel CreateInlineField(string labelText, Control content) + { + Label label = new() + { + Text = labelText, + AutoSize = true, + Anchor = AnchorStyles.Left, + Margin = new Padding(0, 0, 6, 0) + }; + + content.Margin = new Padding(0); + + TableLayoutPanel panel = new() + { + Dock = DockStyle.Fill, + ColumnCount = 2, + Margin = new Padding(0), + Padding = new Padding(0) + }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + panel.Controls.Add(label, 0, 0); + panel.Controls.Add(content, 1, 0); + return panel; + } + + private static TableLayoutPanel CreateRow(params Control[] controls) + { + TableLayoutPanel row = new() + { + Dock = DockStyle.Fill, + ColumnCount = controls.Length, + Margin = new Padding(0) + }; + + for (int index = 0; index < controls.Length; index++) + { + row.ColumnStyles.Add(index == 0 ? new ColumnStyle(SizeType.Percent, 100F) : new ColumnStyle(SizeType.AutoSize)); + row.Controls.Add(controls[index], index, 0); + } + + return row; + } + + private static Panel CreateSingleRow(Control control) + { + Panel panel = new() + { + Dock = DockStyle.Fill, + Margin = new Padding(0), + Padding = new Padding(0) + }; + + control.Dock = DockStyle.Fill; + control.Margin = new Padding(0); + panel.Controls.Add(control); + return panel; + } + + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + SetStatus("Ready"); + UpdateValidationSummary(); + } + + private void UpdateValidationSummary() + { + if (_validationSummaryTextBox.IsDisposed) + { + return; + } + + if (_validationSummaryTextBox.InvokeRequired) + { + _validationSummaryTextBox.BeginInvoke(UpdateValidationSummary); + return; + } + + UpdateValidationSummaryCore(_manifestTextBox.Text); + } + + private void UpdateValidationSummary(DeploymentManifest manifest) + { + if (_validationSummaryTextBox.IsDisposed) + { + return; + } + + if (_validationSummaryTextBox.InvokeRequired) + { + _validationSummaryTextBox.BeginInvoke(() => UpdateValidationSummary(manifest)); + return; + } + + int totalSteps = manifest.Jobs.Sum(job => job.Steps.Count); + _validationSummaryTextBox.Text = $""" + Manifest status: valid + Jobs: {manifest.Jobs.Count} + Steps: {totalSteps} + """; + } + + private void UpdateValidationSummaryCore(string manifestText) + { + if (string.IsNullOrWhiteSpace(manifestText)) + { + _validationSummaryTextBox.Text = """ + Manifest status: empty + Jobs: 0 + Steps: 0 + """; + return; + } + + try + { + DeploymentManifest manifest = DeploymentManifestLoader.Parse(manifestText); + ManifestValidator.Validate(manifest); + + int totalSteps = manifest.Jobs.Sum(job => job.Steps.Count); + _validationSummaryTextBox.Text = $""" + Manifest status: valid + Jobs: {manifest.Jobs.Count} + Steps: {totalSteps} + """; + } + catch (Exception ex) + { + _validationSummaryTextBox.Text = $""" + Manifest status: invalid + Error: {ex.Message} + """; + } + } + + private void AuthenticationTypeComboBox_SelectedIndexChanged(object? sender, EventArgs e) + { + UpdateAuthenticationVisibility(); + } + + private void UpdateAuthenticationVisibility() + { + bool showSqlCredentials = IsSqlAuthenticationSelected(); + _usernameLabel.Visible = showSqlCredentials; + _usernameTextBox.Visible = showSqlCredentials; + _passwordLabel.Visible = showSqlCredentials; + _passwordTextBox.Visible = showSqlCredentials; + } + + private bool IsSqlAuthenticationSelected() + { + return _authenticationTypeComboBox.SelectedIndex == 1; + } + + private string BuildConnectionString() + { + string server = _serverTextBox.Text.Trim(); + string database = _connectionDatabaseTextBox.Text.Trim(); + + if (string.IsNullOrWhiteSpace(server)) + { + throw new InvalidOperationException("Enter a SQL Server name."); + } + + if (string.IsNullOrWhiteSpace(database)) + { + throw new InvalidOperationException("Enter a connection database name."); + } + + SqlConnectionStringBuilder builder = new() + { + DataSource = server, + InitialCatalog = database, + Encrypt = _useSslCheckBox.Checked, + TrustServerCertificate = _useSslCheckBox.Checked + }; + + if (IsSqlAuthenticationSelected()) + { + string username = _usernameTextBox.Text.Trim(); + string password = _passwordTextBox.Text; + + if (string.IsNullOrWhiteSpace(username)) + { + throw new InvalidOperationException("Enter a SQL username."); + } + + if (string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException("Enter a SQL password."); + } + + builder.IntegratedSecurity = false; + builder.UserID = username; + builder.Password = password; + } + else + { + builder.IntegratedSecurity = true; + builder.UserID = string.Empty; + builder.Password = string.Empty; + } + + return builder.ConnectionString; + } + + private sealed class TextBoxWriter : TextWriter + { + private readonly TextBox _target; + + public TextBoxWriter(TextBox target) + { + _target = target; + } + + public override Encoding Encoding => Encoding.UTF8; + + public override void WriteLine(string? value) + { + if (_target.IsDisposed) + { + return; + } + + if (_target.InvokeRequired) + { + _target.BeginInvoke(() => _target.AppendText($"{value}{Environment.NewLine}")); + return; + } + + _target.AppendText($"{value}{Environment.NewLine}"); + } + } +} diff --git a/SqlServerAgentJobDeploymentTool/Program.cs b/SqlServerAgentJobDeploymentTool/Program.cs new file mode 100644 index 0000000..8046b50 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/Program.cs @@ -0,0 +1,293 @@ +namespace SqlServerAgentJobDeploymentTool; + +using System.Text.Json; +using System.Text.Json.Serialization; + +internal static class Program +{ + [STAThread] + public static async Task Main(string[] args) + { + try + { + if (ShouldLaunchUi(args)) + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + return 0; + } + + DeploymentOptions options = DeploymentOptions.Parse(args); + + if (options.ShowHelp) + { + PrintUsage(); + return 0; + } + + DeploymentManifest manifest = await DeploymentManifestLoader.LoadAsync(options.ManifestPath, CancellationToken.None); + + if (!string.IsNullOrWhiteSpace(options.ConnectionString)) + { + manifest.ConnectionString = options.ConnectionString; + } + + if (string.IsNullOrWhiteSpace(manifest.ConnectionString)) + { + Console.Error.WriteLine("A connection string must be supplied in the manifest or with --connection-string."); + return 1; + } + + ManifestValidator.Validate(manifest); + + if (options.WhatIf) + { + Console.WriteLine("Dry run. The following jobs would be deployed:"); + foreach (JobDefinition job in manifest.Jobs) + { + Console.WriteLine($"- {job.Name}"); + } + + return 0; + } + + await using var connection = new Microsoft.Data.SqlClient.SqlConnection(manifest.ConnectionString); + await connection.OpenAsync(CancellationToken.None); + + var deployer = new SqlAgentDeploymentService(connection, Console.Out); + await deployer.DeployAsync(manifest, options.DatabaseName, CancellationToken.None); + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + } + + private static bool ShouldLaunchUi(string[] args) + => args.Length == 0 || args.Any(arg => arg.Equals("--ui", StringComparison.OrdinalIgnoreCase)); + + private static void PrintUsage() + { + Console.WriteLine(""" + SQL Server Agent Job Deployment Tool + + Usage: + SqlServerAgentJobDeploymentTool [--ui] + SqlServerAgentJobDeploymentTool --manifest [--connection-string ] [--database-name ] [--dry-run] + + Options: + --ui Launch the desktop UI. + --manifest Path to the deployment manifest JSON file. + --connection-string Overrides the manifest connection string. + --database-name Overrides the database used by SQL Agent T-SQL steps. + --dry-run Validate the manifest and show the jobs without changing SQL Server. + --help Show this help text. + """); + } +} + +internal sealed record DeploymentOptions(string ManifestPath, string? ConnectionString, string? DatabaseName, bool WhatIf, bool ShowHelp) +{ + public static DeploymentOptions Parse(string[] args) + { + string manifestPath = "sql-agent-jobs.json"; + string? connectionString = null; + string? databaseName = null; + bool whatIf = false; + bool showHelp = false; + + for (int index = 0; index < args.Length; index++) + { + string current = args[index]; + + if (current is "--help" or "-h" or "/?") + { + showHelp = true; + continue; + } + + if (current is "--dry-run" or "--what-if") + { + whatIf = true; + continue; + } + + if (current.StartsWith("--manifest=", StringComparison.OrdinalIgnoreCase)) + { + manifestPath = current["--manifest=".Length..]; + continue; + } + + if (current.Equals("--manifest", StringComparison.OrdinalIgnoreCase)) + { + manifestPath = ReadNext(args, ref index, "--manifest"); + continue; + } + + if (current.StartsWith("--connection-string=", StringComparison.OrdinalIgnoreCase)) + { + connectionString = current["--connection-string=".Length..]; + continue; + } + + if (current.Equals("--connection-string", StringComparison.OrdinalIgnoreCase)) + { + connectionString = ReadNext(args, ref index, "--connection-string"); + continue; + } + + if (current.StartsWith("--database-name=", StringComparison.OrdinalIgnoreCase)) + { + databaseName = current["--database-name=".Length..]; + continue; + } + + if (current.Equals("--database-name", StringComparison.OrdinalIgnoreCase)) + { + databaseName = ReadNext(args, ref index, "--database-name"); + continue; + } + + throw new ArgumentException($"Unrecognised argument '{current}'. Use --help for usage."); + } + + return new DeploymentOptions(manifestPath, connectionString, databaseName, whatIf, showHelp); + } + + private static string ReadNext(string[] args, ref int index, string optionName) + { + int nextIndex = index + 1; + if (nextIndex >= args.Length) + { + throw new ArgumentException($"Missing value for '{optionName}'."); + } + + index = nextIndex; + return args[nextIndex]; + } +} + +internal static class DeploymentManifestLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) + } + }; + + public static async Task LoadAsync(string manifestPath, CancellationToken cancellationToken) + { + if (!File.Exists(manifestPath)) + { + throw new FileNotFoundException($"Manifest file '{manifestPath}' was not found.", manifestPath); + } + + await using FileStream stream = File.OpenRead(manifestPath); + string json = await new StreamReader(stream).ReadToEndAsync(cancellationToken); + DeploymentManifest manifest = Parse(json); + + return manifest; + } + + public static string Format(string json) + { + DeploymentManifest manifest = Parse(json); + return Serialize(manifest); + } + + public static string Serialize(DeploymentManifest manifest) + { + JsonSerializerOptions options = new(JsonOptions) + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + return JsonSerializer.Serialize(manifest, options); + } + + public static DeploymentManifest Parse(string json) + { + DeploymentManifest? manifest = JsonSerializer.Deserialize(json, JsonOptions); + + if (manifest is null) + { + throw new InvalidOperationException("The manifest content did not contain a valid deployment manifest."); + } + + return manifest; + } +} + +internal static class ManifestValidator +{ + public static void Validate(DeploymentManifest manifest) + { + if (manifest.Jobs.Count == 0) + { + throw new InvalidOperationException("The manifest does not contain any jobs."); + } + + foreach (JobDefinition job in manifest.Jobs) + { + if (string.IsNullOrWhiteSpace(job.Name)) + { + throw new InvalidOperationException("Each job must have a name."); + } + + if (job.Steps.Count == 0) + { + throw new InvalidOperationException($"Job '{job.Name}' does not contain any steps."); + } + + var stepIds = new HashSet(); + int nextStepId = 1; + foreach (JobStepDefinition step in job.Steps) + { + if (string.IsNullOrWhiteSpace(step.Name)) + { + throw new InvalidOperationException($"Job '{job.Name}' contains a step with no name."); + } + + if (string.IsNullOrWhiteSpace(step.Command)) + { + throw new InvalidOperationException($"Job '{job.Name}' step '{step.Name}' does not contain a command."); + } + + int stepId = step.StepId ?? nextStepId; + nextStepId = stepId + 1; + + if (!stepIds.Add(stepId)) + { + throw new InvalidOperationException($"Job '{job.Name}' contains duplicate step id '{stepId}'."); + } + + if (step.OnSuccessAction == JobStepAction.GoToStep && step.OnSuccessStepId is null) + { + throw new InvalidOperationException($"Job '{job.Name}' step '{step.Name}' must define OnSuccessStepId when OnSuccessAction is GoToStep."); + } + + if (step.OnFailAction == JobStepAction.GoToStep && step.OnFailStepId is null) + { + throw new InvalidOperationException($"Job '{job.Name}' step '{step.Name}' must define OnFailStepId when OnFailAction is GoToStep."); + } + } + + foreach (JobScheduleDefinition schedule in job.Schedules) + { + if (string.IsNullOrWhiteSpace(schedule.Name)) + { + throw new InvalidOperationException($"Job '{job.Name}' contains a schedule with no name."); + } + } + } + } +} diff --git a/SqlServerAgentJobDeploymentTool/README.md b/SqlServerAgentJobDeploymentTool/README.md new file mode 100644 index 0000000..cbebcec --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/README.md @@ -0,0 +1,42 @@ +# SQL Server Agent Job Deployment Tool + +This utility deploys SQL Server Agent jobs from a JSON manifest. + +## Usage + +```text +SqlServerAgentJobDeploymentTool --manifest sql-agent-jobs.sample.json +``` + +Optional overrides: + +- `--connection-string ` +- `--database-name ` +- `--dry-run` +- `--help` + +## Manifest shape + +- `connectionString`: SQL Server connection string for `msdb`. +- `jobs[]`: one or more job definitions. +- Each job contains `steps[]` and optional `schedules[]`. +- The target database is supplied at deploy time from the CLI or the UI. When set, it overrides the database for all T-SQL steps. + +The sample manifest includes the existing settlement and scavenge jobs from this repository. + +## Read model job + +For the `TransactionProcessorReadModel-435613ac-a468-47a3-ac4f-649d89764c22` database, use: + +```text +SqlServerAgentJobDeploymentTool --manifest transactionprocessor-readmodel-transaction-jobs.json +``` + +That manifest deploys one job with two separate steps: + +1. `spBuildTodaysTransactions` +2. `spBuildHistoricTransactions` + +It is scheduled to run every 5 minutes. + +You can set the target database in the UI using the `Database name override` field, or on the command line with `--database-name`. diff --git a/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs b/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs new file mode 100644 index 0000000..82f1a30 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs @@ -0,0 +1,190 @@ +namespace SqlServerAgentJobDeploymentTool; + +using Microsoft.Data.SqlClient; + +internal sealed class SqlAgentDeploymentService +{ + private readonly SqlConnection _connection; + private readonly TextWriter _writer; + + public SqlAgentDeploymentService(SqlConnection connection, TextWriter writer) + { + _connection = connection; + _writer = writer; + } + + public async Task DeployAsync(DeploymentManifest manifest, string? databaseNameOverride, CancellationToken cancellationToken) + { + foreach (JobDefinition job in manifest.Jobs) + { + await DeployJobAsync(job, databaseNameOverride, cancellationToken); + } + } + + private async Task DeployJobAsync(JobDefinition job, string? databaseNameOverride, CancellationToken cancellationToken) + { + if (await JobExistsAsync(job.Name, cancellationToken)) + { + if (!job.ReplaceExisting) + { + throw new InvalidOperationException($"Job '{job.Name}' already exists and ReplaceExisting is false."); + } + + await ExecuteProcedureAsync( + "msdb.dbo.sp_delete_job", + cancellationToken, + new SqlParameter("@job_name", job.Name), + new SqlParameter("@delete_unused_schedule", 1)); + } + + _writer.WriteLine($"Deploying job '{job.Name}'."); + + int deleteLevel = job.DeleteLevel ?? 0; + SqlParameter jobIdParameter = new("@job_id", System.Data.SqlDbType.UniqueIdentifier) + { + Direction = System.Data.ParameterDirection.Output + }; + + List jobParameters = + [ + new SqlParameter("@job_name", job.Name), + new SqlParameter("@enabled", job.Enabled), + new SqlParameter("@description", string.IsNullOrWhiteSpace(job.Description) ? DBNull.Value : job.Description), + new SqlParameter("@owner_login_name", string.IsNullOrWhiteSpace(job.OwnerLoginName) ? DBNull.Value : job.OwnerLoginName), + new SqlParameter("@category_name", string.IsNullOrWhiteSpace(job.CategoryName) ? DBNull.Value : job.CategoryName), + new SqlParameter("@delete_level", deleteLevel), + jobIdParameter + ]; + + await ExecuteProcedureAsync("msdb.dbo.sp_add_job", jobParameters, cancellationToken); + + Guid jobId = (Guid)jobIdParameter.Value; + + int nextStepId = 1; + foreach (JobStepDefinition step in job.Steps) + { + int stepId = step.StepId ?? nextStepId; + nextStepId = stepId + 1; + + await AddJobStepAsync(jobId, step, stepId, databaseNameOverride, cancellationToken); + } + + foreach (JobScheduleDefinition schedule in job.Schedules) + { + await AddJobScheduleAsync(jobId, schedule, cancellationToken); + } + + if (!string.IsNullOrWhiteSpace(job.TargetServerName)) + { + await ExecuteProcedureAsync( + "msdb.dbo.sp_add_jobserver", + cancellationToken, + new SqlParameter("@job_id", jobId), + new SqlParameter("@server_name", job.TargetServerName)); + } + else + { + await ExecuteProcedureAsync( + "msdb.dbo.sp_add_jobserver", + cancellationToken, + new SqlParameter("@job_id", jobId)); + } + } + + private async Task AddJobStepAsync(Guid jobId, JobStepDefinition step, int stepId, string? databaseNameOverride, CancellationToken cancellationToken) + { + List parameters = + [ + new SqlParameter("@job_id", jobId), + new SqlParameter("@step_id", stepId), + new SqlParameter("@step_name", step.Name), + new SqlParameter("@subsystem", ToSubsystemName(step.Subsystem)), + new SqlParameter("@command", step.Command), + new SqlParameter("@database_name", string.IsNullOrWhiteSpace(databaseNameOverride) ? DBNull.Value : databaseNameOverride), + new SqlParameter("@on_success_action", (int)step.OnSuccessAction), + new SqlParameter("@on_success_step_id", step.OnSuccessStepId ?? 0), + new SqlParameter("@on_fail_action", (int)step.OnFailAction), + new SqlParameter("@on_fail_step_id", step.OnFailStepId ?? 0), + new SqlParameter("@retry_attempts", step.RetryAttempts ?? 0), + new SqlParameter("@retry_interval", step.RetryIntervalMinutes ?? 0), + new SqlParameter("@output_file_name", string.IsNullOrWhiteSpace(step.OutputFileName) ? DBNull.Value : step.OutputFileName), + new SqlParameter("@flags", step.AppendToOutputFile ? 2 : 0) + ]; + + if (!string.IsNullOrWhiteSpace(step.ProxyName)) + { + parameters.Add(new SqlParameter("@proxy_name", step.ProxyName)); + } + + await ExecuteProcedureAsync("msdb.dbo.sp_add_jobstep", parameters, cancellationToken); + + _writer.WriteLine($" Added step {stepId}: {step.Name}"); + } + + private async Task AddJobScheduleAsync(Guid jobId, JobScheduleDefinition schedule, CancellationToken cancellationToken) + { + List parameters = + [ + new SqlParameter("@job_id", jobId), + new SqlParameter("@name", schedule.Name), + new SqlParameter("@enabled", schedule.Enabled), + new SqlParameter("@freq_type", (int)schedule.FrequencyType), + new SqlParameter("@freq_interval", schedule.FrequencyInterval), + new SqlParameter("@freq_subday_type", (int)schedule.FrequencySubdayType), + new SqlParameter("@freq_subday_interval", schedule.FrequencySubdayInterval), + new SqlParameter("@freq_relative_interval", schedule.FrequencyRelativeInterval), + new SqlParameter("@freq_recurrence_factor", schedule.FrequencyRecurrenceFactor), + new SqlParameter("@active_start_date", ToDateInt(schedule.ActiveStartDate ?? DateOnly.FromDateTime(DateTime.Today))), + new SqlParameter("@active_end_date", schedule.ActiveEndDate is null ? 99991231 : ToDateInt(schedule.ActiveEndDate.Value)), + new SqlParameter("@active_start_time", ToTimeInt(schedule.ActiveStartTime ?? TimeOnly.MinValue)), + new SqlParameter("@active_end_time", schedule.ActiveEndTime is null ? 235959 : ToTimeInt(schedule.ActiveEndTime.Value)) + ]; + + await ExecuteProcedureAsync("msdb.dbo.sp_add_jobschedule", parameters, cancellationToken); + + _writer.WriteLine($" Added schedule: {schedule.Name}"); + } + + private static string ToSubsystemName(JobSubsystem subsystem) => subsystem switch + { + JobSubsystem.TSql => "TSQL", + JobSubsystem.CmdExec => "CmdExec", + JobSubsystem.PowerShell => "PowerShell", + JobSubsystem.AnalysisServicesCommand => "ANALYSISCOMMAND", + JobSubsystem.SSIS => "SSIS", + _ => throw new ArgumentOutOfRangeException(nameof(subsystem), subsystem, "Unsupported SQL Agent subsystem.") + }; + + private async Task JobExistsAsync(string jobName, CancellationToken cancellationToken) + { + await using SqlCommand command = _connection.CreateCommand(); + command.CommandText = "select count_big(1) from msdb.dbo.sysjobs where name = @jobName;"; + command.Parameters.Add(new SqlParameter("@jobName", jobName)); + + object? result = await command.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt64(result) > 0; + } + + private async Task ExecuteProcedureAsync(string procedureName, CancellationToken cancellationToken, params SqlParameter[] parameters) + { + await ExecuteProcedureAsync(procedureName, (IEnumerable)parameters, cancellationToken); + } + + private async Task ExecuteProcedureAsync(string procedureName, IEnumerable parameters, CancellationToken cancellationToken) + { + await using SqlCommand command = _connection.CreateCommand(); + command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandText = procedureName; + + foreach (SqlParameter parameter in parameters) + { + command.Parameters.Add(parameter); + } + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static int ToDateInt(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day; + + private static int ToTimeInt(TimeOnly time) => time.Hour * 10000 + time.Minute * 100 + time.Second; +} diff --git a/SqlServerAgentJobDeploymentTool/SqlAgentModels.cs b/SqlServerAgentJobDeploymentTool/SqlAgentModels.cs new file mode 100644 index 0000000..019058b --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/SqlAgentModels.cs @@ -0,0 +1,125 @@ +namespace SqlServerAgentJobDeploymentTool; + +using System.Text.Json.Serialization; + +public sealed class DeploymentManifest +{ + public string? ConnectionString { get; set; } + + public List Jobs { get; set; } = []; +} + +public sealed class JobDefinition +{ + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public bool Enabled { get; set; } = true; + + public bool ReplaceExisting { get; set; } = true; + + public string? OwnerLoginName { get; set; } + + public string? CategoryName { get; set; } + + public string? TargetServerName { get; set; } + + public int? DeleteLevel { get; set; } + + public List Steps { get; set; } = []; + + public List Schedules { get; set; } = []; +} + +public sealed class JobStepDefinition +{ + public int? StepId { get; set; } + + public string Name { get; set; } = string.Empty; + + public JobSubsystem Subsystem { get; set; } = JobSubsystem.TSql; + + public string Command { get; set; } = string.Empty; + + public JobStepAction OnSuccessAction { get; set; } = JobStepAction.QuitWithSuccess; + + public int? OnSuccessStepId { get; set; } + + public JobStepAction OnFailAction { get; set; } = JobStepAction.QuitWithFailure; + + public int? OnFailStepId { get; set; } + + public int? RetryAttempts { get; set; } + + public int? RetryIntervalMinutes { get; set; } + + public string? ProxyName { get; set; } + + public string? OutputFileName { get; set; } + + public bool AppendToOutputFile { get; set; } +} + +public sealed class JobScheduleDefinition +{ + public string Name { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + public JobScheduleFrequency FrequencyType { get; set; } = JobScheduleFrequency.Daily; + + public int FrequencyInterval { get; set; } = 1; + + public int FrequencyRelativeInterval { get; set; } = 1; + + public int FrequencyRecurrenceFactor { get; set; } = 1; + + public JobScheduleSubdayType FrequencySubdayType { get; set; } = JobScheduleSubdayType.Once; + + public int FrequencySubdayInterval { get; set; } = 0; + + public DateOnly? ActiveStartDate { get; set; } + + public TimeOnly? ActiveStartTime { get; set; } + + public DateOnly? ActiveEndDate { get; set; } + + public TimeOnly? ActiveEndTime { get; set; } +} + +public enum JobSubsystem +{ + TSql = 1, + CmdExec = 2, + PowerShell = 3, + AnalysisServicesCommand = 4, + SSIS = 5 +} + +public enum JobStepAction +{ + QuitWithSuccess = 1, + QuitWithFailure = 2, + GoToNextStep = 3, + GoToStep = 4 +} + +public enum JobScheduleFrequency +{ + OneTime = 1, + Daily = 4, + Weekly = 8, + Monthly = 16, + MonthlyRelative = 32, + AgentStart = 64, + Idle = 128 +} + +public enum JobScheduleSubdayType +{ + Once = 1, + Seconds = 2, + Minutes = 4, + Hours = 8 +} diff --git a/SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.csproj b/SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.csproj new file mode 100644 index 0000000..51a8051 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.csproj @@ -0,0 +1,28 @@ + + + + WinExe + net10.0-windows + enable + enable + true + true + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.sln b/SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.sln new file mode 100644 index 0000000..61d9f5a --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/SqlServerAgentJobDeploymentTool.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServerAgentJobDeploymentTool", "SqlServerAgentJobDeploymentTool.csproj", "{4F5A8D83-5A16-4FA6-8C59-7EE8F8D1D2E9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4F5A8D83-5A16-4FA6-8C59-7EE8F8D1D2E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F5A8D83-5A16-4FA6-8C59-7EE8F8D1D2E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F5A8D83-5A16-4FA6-8C59-7EE8F8D1D2E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F5A8D83-5A16-4FA6-8C59-7EE8F8D1D2E9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/SqlServerAgentJobDeploymentTool/sql-agent-jobs.sample.json b/SqlServerAgentJobDeploymentTool/sql-agent-jobs.sample.json new file mode 100644 index 0000000..221c764 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/sql-agent-jobs.sample.json @@ -0,0 +1,52 @@ +{ + "jobs": [ + { + "name": "Daily Settlement", + "description": "Runs the daily settlement PowerShell script.", + "enabled": true, + "replaceExisting": true, + "steps": [ + { + "name": "Run daily settlement", + "subsystem": "cmdExec", + "command": "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\\txnproc\\transactionprocessing\\powershell\\Invoke-DailySettlementProcessing.ps1\" -EstateId \"435613ac-a468-47a3-ac4f-649d89764c22\" -BaseUrl \"http://localhost:5002\" -SecurityServiceUrl \"https://192.168.1.163:5001\" -ClientId \"serviceClient\" -ClientSecret \"REPLACE_ME\"", + "onSuccessAction": "quitWithSuccess", + "onFailAction": "quitWithFailure" + } + ], + "schedules": [ + { + "name": "Daily Settlement - 2AM", + "enabled": true, + "frequencyType": "daily", + "frequencyInterval": 1, + "activeStartTime": "02:00:00" + } + ] + }, + { + "name": "Scavenge", + "description": "Runs the weekly scavenge script.", + "enabled": true, + "replaceExisting": true, + "steps": [ + { + "name": "Run scavenge", + "subsystem": "cmdExec", + "command": "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"C:\\txnproc\\transactionprocessing\\powershell\\scavenge.ps1\" -BaseUrl \"http://localhost:2113\"", + "onSuccessAction": "quitWithSuccess", + "onFailAction": "quitWithFailure" + } + ], + "schedules": [ + { + "name": "Scavenge - Sunday 1AM", + "enabled": true, + "frequencyType": "weekly", + "frequencyInterval": 1, + "activeStartTime": "01:00:00" + } + ] + } + ] +} diff --git a/SqlServerAgentJobDeploymentTool/transactionprocessor-readmodel-transaction-jobs.json b/SqlServerAgentJobDeploymentTool/transactionprocessor-readmodel-transaction-jobs.json new file mode 100644 index 0000000..c60a4f7 --- /dev/null +++ b/SqlServerAgentJobDeploymentTool/transactionprocessor-readmodel-transaction-jobs.json @@ -0,0 +1,39 @@ +{ + "jobs": [ + { + "name": "Transaction Summary Builder", + "description": "Builds today's and historic transactions for the TransactionProcessor read model.", + "enabled": true, + "replaceExisting": true, + "steps": [ + { + "stepId": 1, + "name": "Build today's transactions", + "subsystem": "tSql", + "command": "EXEC dbo.spBuildTodaysTransactions;", + "onSuccessAction": "goToNextStep", + "onFailAction": "quitWithFailure" + }, + { + "stepId": 2, + "name": "Build historic transactions", + "subsystem": "tSql", + "command": "EXEC dbo.spBuildHistoricTransactions;", + "onSuccessAction": "quitWithSuccess", + "onFailAction": "quitWithFailure" + } + ], + "schedules": [ + { + "name": "Every 5 minutes", + "enabled": true, + "frequencyType": "daily", + "frequencyInterval": 1, + "frequencySubdayType": "minutes", + "frequencySubdayInterval": 5, + "activeStartTime": "00:00:00" + } + ] + } + ] +}