From 8df61923357b5937844de813ceb7585ee08e8ef3 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 26 Aug 2026 11:17:13 +0100 Subject: [PATCH 1/3] Fix remaining Codacy bug findings --- ScheduledTasks/AutoScavenge/scavenge.ps1 | 5 +- ScheduledTasks/Tests/Scavenge.Tests.ps1 | 4 +- SqlServerAgentJobDeploymentTool/MainForm.cs | 189 +++++++------ SqlServerAgentJobDeploymentTool/Program.cs | 261 ++++++++++-------- .../SqlAgentDeploymentService.cs | 89 ++++-- .../MerchantProcessingConfigurationStore.cs | 123 ++++++--- .../Program.cs | 60 +++- 7 files changed, 454 insertions(+), 277 deletions(-) diff --git a/ScheduledTasks/AutoScavenge/scavenge.ps1 b/ScheduledTasks/AutoScavenge/scavenge.ps1 index c048bca..a730b8b 100644 --- a/ScheduledTasks/AutoScavenge/scavenge.ps1 +++ b/ScheduledTasks/AutoScavenge/scavenge.ps1 @@ -1,7 +1,7 @@ param( [string]$BaseUrl = "http://localhost:2113", [string]$Username, - [string]$Password, + [System.Security.SecureString]$Password, [System.Management.Automation.PSCredential]$Credential ) @@ -20,7 +20,8 @@ if ($Credential) { $headers['Authorization'] = "Basic $encoded" } elseif (-not [string]::IsNullOrWhiteSpace($Username) -and -not [string]::IsNullOrWhiteSpace($Password)) { - $pair = "$Username`:$Password" + $usernameCredential = [System.Management.Automation.PSCredential]::new($Username, $Password) + $pair = "$($usernameCredential.UserName)`:$($usernameCredential.GetNetworkCredential().Password)" $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair) $encoded = [Convert]::ToBase64String($bytes) $headers['Authorization'] = "Basic $encoded" diff --git a/ScheduledTasks/Tests/Scavenge.Tests.ps1 b/ScheduledTasks/Tests/Scavenge.Tests.ps1 index ccedb10..c023ac5 100644 --- a/ScheduledTasks/Tests/Scavenge.Tests.ps1 +++ b/ScheduledTasks/Tests/Scavenge.Tests.ps1 @@ -47,7 +47,7 @@ Describe 'AutoScavenge\scavenge.ps1' { $Method -eq 'Post' } - & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Username 'alice' -Password 'secret' + & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Username 'alice' -Password (ConvertTo-SecureString 'secret' -AsPlainText -Force) $captured.Count | Should -Be 1 $captured.Uri | Should -Be $expectedUri @@ -93,7 +93,7 @@ Describe 'AutoScavenge\scavenge.ps1' { $Method -eq 'Post' } - & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Password 'secret' + & $script:scriptUnderTest -BaseUrl 'http://localhost:2113' -Password (ConvertTo-SecureString 'secret' -AsPlainText -Force) $captured.Count | Should -Be 1 $captured.Uri | Should -Be 'http://localhost:2113/admin/scavenge' diff --git a/SqlServerAgentJobDeploymentTool/MainForm.cs b/SqlServerAgentJobDeploymentTool/MainForm.cs index 9ac8bb1..99e2efa 100644 --- a/SqlServerAgentJobDeploymentTool/MainForm.cs +++ b/SqlServerAgentJobDeploymentTool/MainForm.cs @@ -9,27 +9,27 @@ internal sealed class MainForm : Form { private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - 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; + private TextBox _manifestPathTextBox = null!; + private TextBox _serverTextBox = null!; + private TextBox _connectionDatabaseTextBox = null!; + private TextBox _databaseNameTextBox = null!; + private CheckBox _useSslCheckBox = null!; + private ComboBox _authenticationTypeComboBox = null!; + private TextBox _usernameTextBox = null!; + private TextBox _passwordTextBox = null!; + private Label _usernameLabel = null!; + private Label _passwordLabel = null!; + private TextBox _manifestTextBox = null!; + private TextBox _outputTextBox = null!; + private Button _loadManifestButton = null!; + private Button _browseButton = null!; + private Button _saveButton = null!; + private Button _saveAsButton = null!; + private Button _formatButton = null!; + private Button _dryRunButton = null!; + private Button _deployButton = null!; + private ToolStripStatusLabel _statusLabel = null!; + private TextBox _validationSummaryTextBox = null!; public MainForm(ILoggerFactory loggerFactory) { @@ -42,10 +42,31 @@ public MainForm(ILoggerFactory loggerFactory) Font = new Font("Segoe UI", 9F); BackColor = Color.FromArgb(245, 247, 250); + var statusStrip = CreateStatusStrip(); + var headerPanel = CreateHeaderPanel(); + var split = CreateSplitContainer(); + split.Panel1.Controls.Add(CreateLeftPanel()); + split.Panel2.Controls.Add(CreateRightPanel()); + + Controls.Add(split); + Controls.Add(headerPanel); + Controls.Add(statusStrip); + + Load += MainForm_Load; + _manifestTextBox.TextChanged += ManifestTextBox_TextChanged; + UpdateAuthenticationVisibility(); + } + + private StatusStrip CreateStatusStrip() + { StatusStrip statusStrip = new(); _statusLabel = new ToolStripStatusLabel("Ready"); statusStrip.Items.Add(_statusLabel); + return statusStrip; + } + private Panel CreateHeaderPanel() + { Panel headerPanel = new() { Dock = DockStyle.Top, @@ -79,14 +100,21 @@ public MainForm(ILoggerFactory loggerFactory) }, 0, 1); headerPanel.Controls.Add(headerLayout); + return headerPanel; + } - SplitContainer split = new() + private SplitContainer CreateSplitContainer() + { + return new SplitContainer { Dock = DockStyle.Fill, SplitterDistance = 820, FixedPanel = FixedPanel.Panel2 }; + } + private Panel CreateLeftPanel() + { Label manifestPathLabel = new() { Text = "Manifest", AutoSize = true }; _manifestPathTextBox = new TextBox { Dock = DockStyle.Fill }; _browseButton = new Button { Text = "Browse..." }; @@ -102,7 +130,38 @@ public MainForm(ILoggerFactory loggerFactory) _formatButton.Click += FormatButton_Click; TableLayoutPanel manifestPathRow = CreateRow(_manifestPathTextBox, _browseButton, _loadManifestButton, _saveButton, _saveAsButton, _formatButton); + Panel connectionGroup = CreateConnectionGroup(); + TableLayoutPanel databaseRow = CreateLabeledPanel( + new Label { Text = "Step database override", AutoSize = true }, + CreateSingleRow(_databaseNameTextBox = new TextBox { Dock = DockStyle.Fill })); + Panel manifestWorkArea = CreateManifestWorkArea(); + + 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); + Panel leftPanel = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(10) + }; + leftPanel.Controls.Add(leftLayout); + return leftPanel; + } + + private Panel CreateConnectionGroup() + { Panel connectionGroup = new() { AutoSize = true, @@ -123,7 +182,7 @@ public MainForm(ILoggerFactory loggerFactory) _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) }; + _useSslCheckBox = new CheckBox { Text = "Encrypt connection", AutoSize = true, Margin = new Padding(0) }; _authenticationTypeComboBox = new ComboBox { Dock = DockStyle.Fill, @@ -136,6 +195,8 @@ public MainForm(ILoggerFactory loggerFactory) _usernameTextBox = new TextBox { Dock = DockStyle.Fill, Margin = new Padding(0) }; _passwordTextBox = new TextBox { Dock = DockStyle.Fill, UseSystemPasswordChar = true, Margin = new Padding(0) }; + _usernameLabel = CreateCompactLabel("Username"); + _passwordLabel = CreateCompactLabel("Password"); TableLayoutPanel connectionLayout = new() { @@ -159,11 +220,8 @@ public MainForm(ILoggerFactory loggerFactory) 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); @@ -171,13 +229,11 @@ public MainForm(ILoggerFactory loggerFactory) connectionGroup.Controls.Add(connectionLayout); connectionGroup.Controls.Add(connectionTitle); + return connectionGroup; + } - _databaseNameTextBox = new TextBox { Dock = DockStyle.Fill }; - - TableLayoutPanel databaseRow = CreateLabeledPanel( - new Label { Text = "Step database override", AutoSize = true }, - CreateSingleRow(_databaseNameTextBox)); - + private Panel CreateManifestWorkArea() + { GroupBox manifestGroup = new() { Dock = DockStyle.Fill, @@ -217,18 +273,10 @@ public MainForm(ILoggerFactory loggerFactory) _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, @@ -237,23 +285,11 @@ public MainForm(ILoggerFactory loggerFactory) }; manifestWorkArea.Controls.Add(manifestGroup); manifestWorkArea.Controls.Add(actionsGroup); + return manifestWorkArea; + } - 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); - + private Panel CreateRightPanel() + { GroupBox outputGroup = new() { Dock = DockStyle.Fill, @@ -271,21 +307,6 @@ public MainForm(ILoggerFactory loggerFactory) }; 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, @@ -303,24 +324,24 @@ public MainForm(ILoggerFactory loggerFactory) }; validationGroup.Controls.Add(_validationSummaryTextBox); - rightLayout.RowStyles.Clear(); + 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)); - 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(); + Panel rightPanel = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(10) + }; + rightPanel.Controls.Add(rightLayout); + return rightPanel; } private async void MainForm_Load(object? sender, EventArgs e) diff --git a/SqlServerAgentJobDeploymentTool/Program.cs b/SqlServerAgentJobDeploymentTool/Program.cs index b1af812..229dc5c 100644 --- a/SqlServerAgentJobDeploymentTool/Program.cs +++ b/SqlServerAgentJobDeploymentTool/Program.cs @@ -29,110 +29,115 @@ public static async Task Main(string[] args) }); Microsoft.Extensions.Logging.ILogger logger = loggerFactory.CreateLogger(nameof(Program)); - string currentOperation = "starting"; - string? currentContext = null; try { - logger.LogInformation("Starting SQL Server Agent Job Deployment Tool."); - - if (ShouldLaunchUi(args)) + return await RunAsync(args, loggerFactory, logger); + } + catch (Exception ex) + { + DeploymentErrorReporter.ReportCli(ex, logger, Console.Error, "starting", null); + return 1; + } + finally + { + try { - ApplicationConfiguration.Initialize(); - Application.Run(new MainForm(loggerFactory)); - return 0; + LogManager.Shutdown(); } - - DeploymentOptions options = DeploymentOptions.Parse(args); - logger.LogInformation("Command-line mode selected. Manifest path: {ManifestPath}. Dry run: {DryRun}. Database override: {DatabaseOverride}.", - options.ManifestPath, - options.WhatIf, - string.IsNullOrWhiteSpace(options.DatabaseName) ? "" : options.DatabaseName); - - if (options.ShowHelp) + catch (Exception shutdownEx) { - PrintUsage(); - logger.LogInformation("Program completed with exit code 0."); - return 0; + logger.LogWarning(shutdownEx, "NLog shutdown failed."); } + } + } - currentOperation = "loading manifest"; - currentContext = options.ManifestPath; - DeploymentManifest manifest = await DeploymentManifestLoader.LoadAsync(options.ManifestPath, CancellationToken.None); - logger.LogInformation("Loaded manifest with {JobCount} job(s).", manifest.Jobs.Count); + private static async Task RunAsync( + string[] args, + ILoggerFactory loggerFactory, + Microsoft.Extensions.Logging.ILogger logger) + { + logger.LogInformation("Starting SQL Server Agent Job Deployment Tool."); - if (!string.IsNullOrWhiteSpace(options.ConnectionString)) - { - manifest.ConnectionString = options.ConnectionString; - logger.LogInformation("Using connection string override from command line."); - } + if (ShouldLaunchUi(args)) + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm(loggerFactory)); + return 0; + } - if (string.IsNullOrWhiteSpace(manifest.ConnectionString)) - { - Console.Error.WriteLine("A connection string must be supplied in the manifest or with --connection-string."); - return 1; - } + DeploymentOptions options = DeploymentOptions.Parse(args); + logger.LogInformation("Command-line mode selected. Manifest path: {ManifestPath}. Dry run: {DryRun}. Database override: {DatabaseOverride}.", + options.ManifestPath, + options.WhatIf, + string.IsNullOrWhiteSpace(options.DatabaseName) ? "" : options.DatabaseName); - currentOperation = "validating manifest"; - currentContext = options.ManifestPath; - ManifestValidator.Validate(manifest); - logger.LogInformation("Manifest validation succeeded."); + if (options.ShowHelp) + { + PrintUsage(); + logger.LogInformation("Program completed with exit code 0."); + return 0; + } - if (options.WhatIf) - { - logger.LogInformation("Dry run requested. The following jobs would be deployed:"); - foreach (JobDefinition job in manifest.Jobs) - { - logger.LogInformation("Dry run job: {JobName}", job.Name); - } - - logger.LogInformation("Program completed with exit code 0."); - return 0; - } + string currentOperation = "loading manifest"; + string? currentContext = options.ManifestPath; + DeploymentManifest manifest = await DeploymentManifestLoader.LoadAsync(options.ManifestPath, CancellationToken.None); + logger.LogInformation("Loaded manifest with {JobCount} job(s).", manifest.Jobs.Count); - currentOperation = "opening SQL connection"; - currentContext = DeploymentErrorReporter.DescribeConnectionTarget(manifest.ConnectionString); - Microsoft.Data.SqlClient.SqlConnection connection = new(manifest.ConnectionString); + if (!string.IsNullOrWhiteSpace(options.ConnectionString)) + { + manifest.ConnectionString = options.ConnectionString; + logger.LogInformation("Using connection string override from command line."); + } - try - { - logger.LogInformation("Opening SQL connection to {ConnectionTarget}.", currentContext); - await connection.OpenAsync(CancellationToken.None); - logger.LogInformation("SQL connection opened to {ConnectionTarget}.", currentContext); - - currentOperation = "deploying SQL Agent jobs"; - var deployer = new SqlAgentDeploymentService(connection, Console.Out, loggerFactory.CreateLogger()); - await deployer.DeployAsync(manifest, options.DatabaseName, CancellationToken.None); - logger.LogInformation("Deployment completed successfully."); - logger.LogInformation("Program completed with exit code 0."); - return 0; - } - finally + if (string.IsNullOrWhiteSpace(manifest.ConnectionString)) + { + Console.Error.WriteLine("A connection string must be supplied in the manifest or with --connection-string."); + return 1; + } + + currentOperation = "validating manifest"; + ManifestValidator.Validate(manifest); + logger.LogInformation("Manifest validation succeeded."); + + if (options.WhatIf) + { + logger.LogInformation("Dry run requested. The following jobs would be deployed:"); + foreach (JobDefinition job in manifest.Jobs) { - try - { - await connection.DisposeAsync(); - } - catch (Exception disposeEx) - { - logger.LogWarning(disposeEx, "Failed to dispose SQL connection after {Operation}.", currentOperation); - } + logger.LogInformation("Dry run job: {JobName}", job.Name); } + + logger.LogInformation("Program completed with exit code 0."); + return 0; } - catch (Exception ex) + + currentOperation = "opening SQL connection"; + currentContext = DeploymentErrorReporter.DescribeConnectionTarget(manifest.ConnectionString); + Microsoft.Data.SqlClient.SqlConnection connection = new(manifest.ConnectionString); + + try { - DeploymentErrorReporter.ReportCli(ex, logger, Console.Error, currentOperation, currentContext); - return 1; + logger.LogInformation("Opening SQL connection to {ConnectionTarget}.", currentContext); + await connection.OpenAsync(CancellationToken.None); + logger.LogInformation("SQL connection opened to {ConnectionTarget}.", currentContext); + + currentOperation = "deploying SQL Agent jobs"; + var deployer = new SqlAgentDeploymentService(connection, Console.Out, loggerFactory.CreateLogger()); + await deployer.DeployAsync(manifest, options.DatabaseName, CancellationToken.None); + logger.LogInformation("Deployment completed successfully."); + logger.LogInformation("Program completed with exit code 0."); + return 0; } finally { try { - LogManager.Shutdown(); + await connection.DisposeAsync(); } - catch (Exception shutdownEx) + catch (Exception disposeEx) { - logger.LogWarning(shutdownEx, "NLog shutdown failed."); + logger.LogWarning(disposeEx, "Failed to dispose SQL connection after {Operation}.", currentOperation); } } } @@ -310,55 +315,75 @@ public static void Validate(DeploymentManifest manifest) foreach (JobDefinition job in manifest.Jobs) { - if (string.IsNullOrWhiteSpace(job.Name)) + ValidateJob(job); + } + } + + private static void ValidateJob(JobDefinition job) + { + 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."); + } + + ValidateSteps(job); + ValidateSchedules(job); + } + + private static void ValidateSteps(JobDefinition job) + { + var stepIds = new HashSet(); + int nextStepId = 1; + + foreach (JobStepDefinition step in job.Steps) + { + if (string.IsNullOrWhiteSpace(step.Name)) { - throw new InvalidOperationException("Each job must have a name."); + throw new InvalidOperationException($"Job '{job.Name}' contains a step with no name."); } - if (job.Steps.Count == 0) + if (string.IsNullOrWhiteSpace(step.Command)) { - throw new InvalidOperationException($"Job '{job.Name}' does not contain any steps."); + throw new InvalidOperationException($"Job '{job.Name}' step '{step.Name}' does not contain a command."); } - var stepIds = new HashSet(); - int nextStepId = 1; - foreach (JobStepDefinition step in job.Steps) + int stepId = step.StepId ?? nextStepId; + nextStepId = stepId + 1; + + if (!stepIds.Add(stepId)) { - 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."); - } + throw new InvalidOperationException($"Job '{job.Name}' contains duplicate step id '{stepId}'."); } - foreach (JobScheduleDefinition schedule in job.Schedules) + ValidateStepReferences(job, step); + } + } + + private static void ValidateStepReferences(JobDefinition job, JobStepDefinition step) + { + 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."); + } + } + + private static void ValidateSchedules(JobDefinition job) + { + foreach (JobScheduleDefinition schedule in job.Schedules) + { + if (string.IsNullOrWhiteSpace(schedule.Name)) { - if (string.IsNullOrWhiteSpace(schedule.Name)) - { - throw new InvalidOperationException($"Job '{job.Name}' contains a schedule with no name."); - } + throw new InvalidOperationException($"Job '{job.Name}' contains a schedule with no name."); } } } diff --git a/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs b/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs index 430cbcd..17777bf 100644 --- a/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs +++ b/SqlServerAgentJobDeploymentTool/SqlAgentDeploymentService.cs @@ -45,11 +45,7 @@ private async Task DeployJobAsync(JobDefinition job, string? databaseNameOverrid } _logger.LogInformation("Replacing existing job {JobName}.", job.Name); - await ExecuteProcedureAsync( - "msdb.dbo.sp_delete_job", - cancellationToken, - new SqlParameter("@job_name", job.Name), - new SqlParameter("@delete_unused_schedule", 1)); + await DeleteJobAsync(job.Name, cancellationToken); } _writer.WriteLine($"Deploying job '{job.Name}'."); @@ -75,7 +71,7 @@ await ExecuteProcedureAsync( jobIdParameter ]; - await ExecuteProcedureAsync("msdb.dbo.sp_add_job", jobParameters, cancellationToken); + await AddJobAsync(jobParameters, cancellationToken); Guid jobId = (Guid)jobIdParameter.Value; _logger.LogInformation("Created job {JobName} with job id {JobId}.", job.Name, jobId); @@ -97,19 +93,12 @@ await ExecuteProcedureAsync( if (!string.IsNullOrWhiteSpace(job.TargetServerName)) { _logger.LogInformation("Binding job {JobName} to target server {TargetServer}.", job.Name, job.TargetServerName); - await ExecuteProcedureAsync( - "msdb.dbo.sp_add_jobserver", - cancellationToken, - new SqlParameter("@job_id", jobId), - new SqlParameter("@server_name", job.TargetServerName)); + await AddJobServerAsync(jobId, job.TargetServerName, cancellationToken); } else { _logger.LogInformation("Binding job {JobName} to the local server.", job.Name); - await ExecuteProcedureAsync( - "msdb.dbo.sp_add_jobserver", - cancellationToken, - new SqlParameter("@job_id", jobId)); + await AddJobServerAsync(jobId, null, cancellationToken); } _logger.LogInformation("Finished job {JobName}.", job.Name); @@ -142,7 +131,7 @@ private async Task AddJobStepAsync(Guid jobId, JobStepDefinition step, int stepI parameters.Add(new SqlParameter("@proxy_name", step.ProxyName)); } - await ExecuteProcedureAsync("msdb.dbo.sp_add_jobstep", parameters, cancellationToken); + await AddJobStepAsync(parameters, cancellationToken); _writer.WriteLine($" Added step {stepId}: {step.Name}"); _logger.LogInformation("Added step {StepId} for job {JobId}: {StepName}.", stepId, jobId, step.Name); @@ -169,7 +158,7 @@ private async Task AddJobScheduleAsync(Guid jobId, JobScheduleDefinition schedul new SqlParameter("@active_end_time", schedule.ActiveEndTime is null ? 235959 : ToTimeInt(schedule.ActiveEndTime.Value)) ]; - await ExecuteProcedureAsync("msdb.dbo.sp_add_jobschedule", parameters, cancellationToken); + await AddJobScheduleAsync(parameters, cancellationToken); _writer.WriteLine($" Added schedule: {schedule.Name}"); _logger.LogInformation("Added schedule {ScheduleName} for job {JobId}.", schedule.Name, jobId); @@ -199,27 +188,81 @@ private async Task JobExistsAsync(string jobName, CancellationToken cancel return exists; } - private async Task ExecuteProcedureAsync(string procedureName, CancellationToken cancellationToken, params SqlParameter[] parameters) + private async Task DeleteJobAsync(string jobName, CancellationToken cancellationToken) { - await ExecuteProcedureAsync(procedureName, (IEnumerable)parameters, cancellationToken); + await using SqlCommand command = _connection.CreateCommand(); + command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandText = "msdb.dbo.sp_delete_job"; + + await ExecuteCommandAsync( + command, + new[] + { + new SqlParameter("@job_name", jobName), + new SqlParameter("@delete_unused_schedule", 1) + }, + "msdb.dbo.sp_delete_job", + cancellationToken); + } + + private async Task AddJobAsync(IEnumerable parameters, CancellationToken cancellationToken) + { + await using SqlCommand command = _connection.CreateCommand(); + command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandText = "msdb.dbo.sp_add_job"; + await ExecuteCommandAsync(command, parameters, "msdb.dbo.sp_add_job", cancellationToken); + } + + private async Task AddJobStepAsync(IEnumerable parameters, CancellationToken cancellationToken) + { + await using SqlCommand command = _connection.CreateCommand(); + command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandText = "msdb.dbo.sp_add_jobstep"; + await ExecuteCommandAsync(command, parameters, "msdb.dbo.sp_add_jobstep", cancellationToken); } - private async Task ExecuteProcedureAsync(string procedureName, IEnumerable parameters, CancellationToken cancellationToken) + private async Task AddJobScheduleAsync(IEnumerable parameters, CancellationToken cancellationToken) { await using SqlCommand command = _connection.CreateCommand(); command.CommandType = System.Data.CommandType.StoredProcedure; - command.CommandText = procedureName; + command.CommandText = "msdb.dbo.sp_add_jobschedule"; + await ExecuteCommandAsync(command, parameters, "msdb.dbo.sp_add_jobschedule", cancellationToken); + } + + private async Task AddJobServerAsync(Guid jobId, string? targetServerName, CancellationToken cancellationToken) + { + List parameters = + [ + new SqlParameter("@job_id", jobId) + ]; + + if (!string.IsNullOrWhiteSpace(targetServerName)) + { + parameters.Add(new SqlParameter("@server_name", targetServerName)); + } + await using SqlCommand command = _connection.CreateCommand(); + command.CommandType = System.Data.CommandType.StoredProcedure; + command.CommandText = "msdb.dbo.sp_add_jobserver"; + await ExecuteCommandAsync(command, parameters, "msdb.dbo.sp_add_jobserver", cancellationToken); + } + + private async Task ExecuteCommandAsync( + SqlCommand command, + IEnumerable parameters, + string operationName, + CancellationToken cancellationToken) + { foreach (SqlParameter parameter in parameters) { command.Parameters.Add(parameter); } Stopwatch stopwatch = Stopwatch.StartNew(); - _logger.LogTrace("Executing stored procedure {ProcedureName}.", procedureName); + _logger.LogTrace("Executing stored procedure {ProcedureName}.", operationName); await command.ExecuteNonQueryAsync(cancellationToken); stopwatch.Stop(); - _logger.LogTrace("Executed stored procedure {ProcedureName} in {ElapsedMilliseconds} ms.", procedureName, stopwatch.ElapsedMilliseconds); + _logger.LogTrace("Executed stored procedure {ProcedureName} in {ElapsedMilliseconds} ms.", operationName, stopwatch.ElapsedMilliseconds); } private static int ToDateInt(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day; diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs index fb9133e..2a65633 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs @@ -130,35 +130,65 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() private async Task LoadRelationalConfigurationAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) { - var authentication = await dbContext.MerchantProcessingAuthenticationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); - var fileProcessing = await dbContext.MerchantProcessingFileProcessingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); - var transactionGeneration = await dbContext.MerchantProcessingTransactionGenerationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); - var fileStatusPolling = await dbContext.MerchantProcessingFileStatusPollingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); - var merchantScan = await dbContext.MerchantProcessingMerchantScanRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); - - var fileProfiles = await dbContext.MerchantProcessingFileProfileRecords.AsNoTracking() - .OrderBy(record => record.SortOrder) - .ToListAsync(cancellationToken); - - var contractDefinitions = await dbContext.MerchantProcessingContractDefinitionRecords.AsNoTracking() - .OrderBy(record => record.SortOrder) - .ToListAsync(cancellationToken); - - var merchants = await dbContext.MerchantProcessingMerchantRecords.AsNoTracking() - .OrderBy(record => record.SortOrder) - .ToListAsync(cancellationToken); + RelationalConfigurationData data = await LoadRelationalConfigurationDataAsync(dbContext, cancellationToken); + if (!HasRelationalConfiguration(data)) + { + return null; + } - if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling is null && merchantScan is null && - fileProfiles.Count == 0 && contractDefinitions.Count == 0 && merchants.Count == 0) + if (!HasRequiredSingletons(data)) { return null; } - if (authentication is null || fileProcessing is null || transactionGeneration is null || fileStatusPolling is null || merchantScan is null) + var options = await BuildRelationalOptions(dbContext, data, cancellationToken); + if (!MerchantProcessingOptionsValidator.Validate(options.Options)) { return null; } + return new RelationalConfigurationSnapshot(options.Options, options.UpdatedUtc); + } + + private async Task LoadRelationalConfigurationDataAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + return new RelationalConfigurationData( + await dbContext.MerchantProcessingAuthenticationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken), + await dbContext.MerchantProcessingFileProcessingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken), + await dbContext.MerchantProcessingTransactionGenerationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken), + await dbContext.MerchantProcessingFileStatusPollingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken), + await dbContext.MerchantProcessingMerchantScanRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken), + await dbContext.MerchantProcessingFileProfileRecords.AsNoTracking().OrderBy(record => record.SortOrder).ToListAsync(cancellationToken), + await dbContext.MerchantProcessingContractDefinitionRecords.AsNoTracking().OrderBy(record => record.SortOrder).ToListAsync(cancellationToken), + await dbContext.MerchantProcessingMerchantRecords.AsNoTracking().OrderBy(record => record.SortOrder).ToListAsync(cancellationToken)); + } + + private static bool HasRelationalConfiguration(RelationalConfigurationData data) + { + return data.Authentication is not null || + data.FileProcessing is not null || + data.TransactionGeneration is not null || + data.FileStatusPolling is not null || + data.MerchantScan is not null || + data.FileProfiles.Count > 0 || + data.ContractDefinitions.Count > 0 || + data.Merchants.Count > 0; + } + + private static bool HasRequiredSingletons(RelationalConfigurationData data) + { + return data.Authentication is not null && + data.FileProcessing is not null && + data.TransactionGeneration is not null && + data.FileStatusPolling is not null && + data.MerchantScan is not null; + } + + private async Task BuildRelationalOptions( + MerchantFileProcessorDbContext dbContext, + RelationalConfigurationData data, + CancellationToken cancellationToken) + { var fileProfileFields = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileFieldRecords, cancellationToken); var headerFieldRecords = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileHeaderFieldRecords, cancellationToken); var trailerFieldRecords = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileTrailerFieldRecords, cancellationToken); @@ -168,28 +198,28 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() { Authentication = new AuthenticationOptions { - ClientId = authentication.ClientId, - ClientSecret = authentication.ClientSecret, - Scope = authentication.Scope, - Audience = authentication.Audience + ClientId = data.Authentication!.ClientId, + ClientSecret = data.Authentication.ClientSecret, + Scope = data.Authentication.Scope, + Audience = data.Authentication.Audience }, FileProcessing = new FileProcessingOptions { - UserId = fileProcessing.UserId + UserId = data.FileProcessing!.UserId }, TransactionGeneration = new TransactionGenerationOptions { - MinimumTransactionsPerContract = transactionGeneration.MinimumTransactionsPerContract, - MaximumTransactionsPerContract = transactionGeneration.MaximumTransactionsPerContract + MinimumTransactionsPerContract = data.TransactionGeneration!.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = data.TransactionGeneration.MaximumTransactionsPerContract }, FileStatusPolling = new FileStatusPollingOptions { - PollIntervalSeconds = fileStatusPolling.PollIntervalSeconds + PollIntervalSeconds = data.FileStatusPolling!.PollIntervalSeconds }, - MerchantScanIntervalSeconds = merchantScan.MerchantScanIntervalSeconds + MerchantScanIntervalSeconds = data.MerchantScan!.MerchantScanIntervalSeconds }; - options.FileProfiles.AddRange(fileProfiles.Select(record => + options.FileProfiles.AddRange(data.FileProfiles.Select(record => { fileProfileFields.TryGetValue(record.Id, out var bodyFields); headerFieldRecords.TryGetValue(record.Id, out var headerFields); @@ -219,13 +249,13 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() }; })); - options.ContractDefinitions.AddRange(contractDefinitions.Select(record => new ContractDefinitionOptions + options.ContractDefinitions.AddRange(data.ContractDefinitions.Select(record => new ContractDefinitionOptions { ContractId = record.ContractId, FileProfileId = record.FileProfileId })); - options.Merchants.AddRange(merchants.Select(record => + options.Merchants.AddRange(data.Merchants.Select(record => { merchantRunTimes.TryGetValue(record.Id, out var runTimes); @@ -240,19 +270,14 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() }; })); - if (!MerchantProcessingOptionsValidator.Validate(options)) - { - return null; - } - var updatedUtc = MaxUtc( - [authentication.UpdatedUtc], - [fileProcessing.UpdatedUtc], - [transactionGeneration.UpdatedUtc], - [fileStatusPolling.UpdatedUtc], - fileProfiles.Select(record => record.UpdatedUtc), - contractDefinitions.Select(record => record.UpdatedUtc), - merchants.Select(record => record.UpdatedUtc)); + [data.Authentication!.UpdatedUtc], + [data.FileProcessing!.UpdatedUtc], + [data.TransactionGeneration!.UpdatedUtc], + [data.FileStatusPolling!.UpdatedUtc], + data.FileProfiles.Select(record => record.UpdatedUtc), + data.ContractDefinitions.Select(record => record.UpdatedUtc), + data.Merchants.Select(record => record.UpdatedUtc)); return new RelationalConfigurationSnapshot(options, updatedUtc); } @@ -279,6 +304,16 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() private sealed record RelationalConfigurationSnapshot(MerchantProcessingOptions Options, DateTimeOffset UpdatedUtc); + private sealed record RelationalConfigurationData( + MerchantProcessingAuthenticationRecord? Authentication, + MerchantProcessingFileProcessingRecord? FileProcessing, + MerchantProcessingTransactionGenerationRecord? TransactionGeneration, + MerchantProcessingFileStatusPollingRecord? FileStatusPolling, + MerchantProcessingMerchantScanRecord? MerchantScan, + List FileProfiles, + List ContractDefinitions, + List Merchants); + private async Task HasNormalizedConfigurationAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) { return await dbContext.MerchantProcessingAuthenticationRecords.AnyAsync(record => record.Id == 1, cancellationToken) || diff --git a/TransactionProcessing.MerchantFileProcessor/Program.cs b/TransactionProcessing.MerchantFileProcessor/Program.cs index c230b24..a59cd1a 100644 --- a/TransactionProcessing.MerchantFileProcessor/Program.cs +++ b/TransactionProcessing.MerchantFileProcessor/Program.cs @@ -247,7 +247,7 @@ static async Task EnsureFileSendSchemaAsync( ("ScheduledRunUtc", "ALTER TABLE FileSendRecords ADD COLUMN ScheduledRunUtc TEXT NOT NULL DEFAULT '0001-01-01T00:00:00+00:00';") }) { - await EnsureColumnAsync(dbContext, existingColumns, columnName, sql, cancellationToken); + await EnsureColumnAsync(dbContext, existingColumns, columnName, cancellationToken); } await ExecuteSqlStatementsAsync(dbContext, new[] @@ -273,6 +273,14 @@ FOREIGN KEY (FileSendRecordId) REFERENCES FileSendRecords (Id) ON DELETE CASCADE static async Task EnsureMerchantProcessingSchemaAsync( MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) +{ + await EnsureMerchantProcessingTablesAsync(dbContext, cancellationToken); + await EnsureMerchantProcessingIndexesAsync(dbContext, cancellationToken); +} + +static async Task EnsureMerchantProcessingTablesAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) { await ExecuteSqlStatementsAsync(dbContext, new[] { @@ -424,7 +432,16 @@ CREATE TABLE IF NOT EXISTS MerchantProcessingConfigurationRecords ( ConfigurationJson TEXT NOT NULL, UpdatedUtc TEXT NOT NULL ); - """, + """ + }, cancellationToken); +} + +static async Task EnsureMerchantProcessingIndexesAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) +{ + await ExecuteSqlStatementsAsync(dbContext, new[] + { "CREATE INDEX IF NOT EXISTS IX_MerchantRunRecords_MerchantId_ScheduledRunUtc_CompletedUtc ON MerchantRunRecords (MerchantId, ScheduledRunUtc, CompletedUtc);", "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfiles_FileProfileId ON MerchantProcessingFileProfiles (FileProfileId);", "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileFields (FileProfileRecordId, SortOrder);", @@ -449,12 +466,47 @@ static async Task EnsureColumnAsync( MerchantFileProcessorDbContext dbContext, ISet existingColumns, string columnName, - string sql, CancellationToken cancellationToken) { if (!existingColumns.Contains(columnName)) { - await dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken); + await ExecuteColumnSqlAsync(dbContext, columnName, cancellationToken); + } +} + +static async Task ExecuteColumnSqlAsync( + MerchantFileProcessorDbContext dbContext, + string columnName, + CancellationToken cancellationToken) +{ + switch (columnName) + { + case "MerchantName": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN MerchantName TEXT NULL;", cancellationToken); + return; + case "ContractName": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN ContractName TEXT NULL;", cancellationToken); + return; + case "FileContent": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN FileContent TEXT NULL;", cancellationToken); + return; + case "EstateId": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN EstateId TEXT NULL;", cancellationToken); + return; + case "FileProcessorFileId": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN FileProcessorFileId TEXT NULL;", cancellationToken); + return; + case "ProcessingCompleted": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN ProcessingCompleted INTEGER NOT NULL DEFAULT 0;", cancellationToken); + return; + case "LastStatusCheckUtc": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN LastStatusCheckUtc TEXT NULL;", cancellationToken); + return; + case "ScheduledRunUtc": + await dbContext.Database.ExecuteSqlRawAsync("ALTER TABLE FileSendRecords ADD COLUMN ScheduledRunUtc TEXT NOT NULL DEFAULT '0001-01-01T00:00:00+00:00';", cancellationToken); + return; + default: + throw new InvalidOperationException($"Unsupported FileSendRecords column '{columnName}'."); } } From 692199951ef5917f0748e75a966d532b382811a7 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 26 Aug 2026 11:22:45 +0100 Subject: [PATCH 2/3] Handle deployment failures with centralized CLI error reporting --- SqlServerAgentJobDeploymentTool/Program.cs | 140 +++++++++++---------- 1 file changed, 76 insertions(+), 64 deletions(-) diff --git a/SqlServerAgentJobDeploymentTool/Program.cs b/SqlServerAgentJobDeploymentTool/Program.cs index 229dc5c..a009d19 100644 --- a/SqlServerAgentJobDeploymentTool/Program.cs +++ b/SqlServerAgentJobDeploymentTool/Program.cs @@ -57,89 +57,101 @@ private static async Task RunAsync( ILoggerFactory loggerFactory, Microsoft.Extensions.Logging.ILogger logger) { - logger.LogInformation("Starting SQL Server Agent Job Deployment Tool."); + string currentOperation = "starting"; + string? currentContext = null; - if (ShouldLaunchUi(args)) + try { - ApplicationConfiguration.Initialize(); - Application.Run(new MainForm(loggerFactory)); - return 0; - } + logger.LogInformation("Starting SQL Server Agent Job Deployment Tool."); - DeploymentOptions options = DeploymentOptions.Parse(args); - logger.LogInformation("Command-line mode selected. Manifest path: {ManifestPath}. Dry run: {DryRun}. Database override: {DatabaseOverride}.", - options.ManifestPath, - options.WhatIf, - string.IsNullOrWhiteSpace(options.DatabaseName) ? "" : options.DatabaseName); - - if (options.ShowHelp) - { - PrintUsage(); - logger.LogInformation("Program completed with exit code 0."); - return 0; - } + if (ShouldLaunchUi(args)) + { + currentOperation = "starting UI"; + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm(loggerFactory)); + return 0; + } - string currentOperation = "loading manifest"; - string? currentContext = options.ManifestPath; - DeploymentManifest manifest = await DeploymentManifestLoader.LoadAsync(options.ManifestPath, CancellationToken.None); - logger.LogInformation("Loaded manifest with {JobCount} job(s).", manifest.Jobs.Count); + DeploymentOptions options = DeploymentOptions.Parse(args); + logger.LogInformation("Command-line mode selected. Manifest path: {ManifestPath}. Dry run: {DryRun}. Database override: {DatabaseOverride}.", + options.ManifestPath, + options.WhatIf, + string.IsNullOrWhiteSpace(options.DatabaseName) ? "" : options.DatabaseName); - if (!string.IsNullOrWhiteSpace(options.ConnectionString)) - { - manifest.ConnectionString = options.ConnectionString; - logger.LogInformation("Using connection string override from command line."); - } + if (options.ShowHelp) + { + PrintUsage(); + logger.LogInformation("Program completed with exit code 0."); + return 0; + } - if (string.IsNullOrWhiteSpace(manifest.ConnectionString)) - { - Console.Error.WriteLine("A connection string must be supplied in the manifest or with --connection-string."); - return 1; - } + currentOperation = "loading manifest"; + currentContext = options.ManifestPath; + DeploymentManifest manifest = await DeploymentManifestLoader.LoadAsync(options.ManifestPath, CancellationToken.None); + logger.LogInformation("Loaded manifest with {JobCount} job(s).", manifest.Jobs.Count); - currentOperation = "validating manifest"; - ManifestValidator.Validate(manifest); - logger.LogInformation("Manifest validation succeeded."); + if (!string.IsNullOrWhiteSpace(options.ConnectionString)) + { + manifest.ConnectionString = options.ConnectionString; + logger.LogInformation("Using connection string override from command line."); + } - if (options.WhatIf) - { - logger.LogInformation("Dry run requested. The following jobs would be deployed:"); - foreach (JobDefinition job in manifest.Jobs) + if (string.IsNullOrWhiteSpace(manifest.ConnectionString)) { - logger.LogInformation("Dry run job: {JobName}", job.Name); + Console.Error.WriteLine("A connection string must be supplied in the manifest or with --connection-string."); + return 1; } - logger.LogInformation("Program completed with exit code 0."); - return 0; - } + currentOperation = "validating manifest"; + ManifestValidator.Validate(manifest); + logger.LogInformation("Manifest validation succeeded."); - currentOperation = "opening SQL connection"; - currentContext = DeploymentErrorReporter.DescribeConnectionTarget(manifest.ConnectionString); - Microsoft.Data.SqlClient.SqlConnection connection = new(manifest.ConnectionString); + if (options.WhatIf) + { + logger.LogInformation("Dry run requested. The following jobs would be deployed:"); + foreach (JobDefinition job in manifest.Jobs) + { + logger.LogInformation("Dry run job: {JobName}", job.Name); + } + + logger.LogInformation("Program completed with exit code 0."); + return 0; + } + + currentOperation = "opening SQL connection"; + currentContext = DeploymentErrorReporter.DescribeConnectionTarget(manifest.ConnectionString); + Microsoft.Data.SqlClient.SqlConnection connection = new(manifest.ConnectionString); - try - { - logger.LogInformation("Opening SQL connection to {ConnectionTarget}.", currentContext); - await connection.OpenAsync(CancellationToken.None); - logger.LogInformation("SQL connection opened to {ConnectionTarget}.", currentContext); - - currentOperation = "deploying SQL Agent jobs"; - var deployer = new SqlAgentDeploymentService(connection, Console.Out, loggerFactory.CreateLogger()); - await deployer.DeployAsync(manifest, options.DatabaseName, CancellationToken.None); - logger.LogInformation("Deployment completed successfully."); - logger.LogInformation("Program completed with exit code 0."); - return 0; - } - finally - { try { - await connection.DisposeAsync(); + logger.LogInformation("Opening SQL connection to {ConnectionTarget}.", currentContext); + await connection.OpenAsync(CancellationToken.None); + logger.LogInformation("SQL connection opened to {ConnectionTarget}.", currentContext); + + currentOperation = "deploying SQL Agent jobs"; + var deployer = new SqlAgentDeploymentService(connection, Console.Out, loggerFactory.CreateLogger()); + await deployer.DeployAsync(manifest, options.DatabaseName, CancellationToken.None); + logger.LogInformation("Deployment completed successfully."); + logger.LogInformation("Program completed with exit code 0."); + return 0; } - catch (Exception disposeEx) + finally { - logger.LogWarning(disposeEx, "Failed to dispose SQL connection after {Operation}.", currentOperation); + try + { + await connection.DisposeAsync(); + } + catch (Exception disposeEx) + { + logger.LogWarning(disposeEx, "Failed to dispose SQL connection after {Operation}.", currentOperation); + } } } + catch (Exception ex) + { + DeploymentErrorReporter.ReportCli(ex, logger, Console.Error, currentOperation, currentContext); + return 1; + } } private static bool ShouldLaunchUi(string[] args) From f8645b9a87cc8d651b7c874b13767364a22f9b8d Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 26 Aug 2026 11:39:26 +0100 Subject: [PATCH 3/3] Split merchant processing schema initialization into focused table setup --- .../Program.cs | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/TransactionProcessing.MerchantFileProcessor/Program.cs b/TransactionProcessing.MerchantFileProcessor/Program.cs index a59cd1a..535df58 100644 --- a/TransactionProcessing.MerchantFileProcessor/Program.cs +++ b/TransactionProcessing.MerchantFileProcessor/Program.cs @@ -274,28 +274,18 @@ static async Task EnsureMerchantProcessingSchemaAsync( MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) { - await EnsureMerchantProcessingTablesAsync(dbContext, cancellationToken); + await EnsureMerchantProcessingCoreTablesAsync(dbContext, cancellationToken); + await EnsureMerchantProcessingProfileTablesAsync(dbContext, cancellationToken); + await EnsureMerchantProcessingMerchantTablesAsync(dbContext, cancellationToken); await EnsureMerchantProcessingIndexesAsync(dbContext, cancellationToken); } -static async Task EnsureMerchantProcessingTablesAsync( +static async Task EnsureMerchantProcessingCoreTablesAsync( MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) { await ExecuteSqlStatementsAsync(dbContext, new[] { - """ - CREATE TABLE IF NOT EXISTS MerchantRunRecords ( - Id INTEGER NOT NULL CONSTRAINT PK_MerchantRunRecords PRIMARY KEY AUTOINCREMENT, - RunId TEXT NOT NULL, - MerchantId TEXT NOT NULL, - MerchantName TEXT NULL, - ScheduledRunUtc TEXT NOT NULL, - Status TEXT NOT NULL, - ErrorMessage TEXT NULL, - CompletedUtc TEXT NOT NULL - ); - """, """ CREATE TABLE IF NOT EXISTS MerchantProcessingAuthenticationRecords ( Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingAuthenticationRecords PRIMARY KEY, @@ -335,6 +325,15 @@ CREATE TABLE IF NOT EXISTS MerchantProcessingMerchantScanRecords ( UpdatedUtc TEXT NOT NULL ); """, + }, cancellationToken); +} + +static async Task EnsureMerchantProcessingProfileTablesAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) +{ + await ExecuteSqlStatementsAsync(dbContext, new[] + { """ CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfiles ( Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfiles PRIMARY KEY AUTOINCREMENT, @@ -393,6 +392,27 @@ CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileTrailerFields ( CONSTRAINT FK_MerchantProcessingFileProfileTrailerFields_MerchantProcessingFileProfiles_FileProfileRecordId FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE ); + """ + }, cancellationToken); +} + +static async Task EnsureMerchantProcessingMerchantTablesAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) +{ + await ExecuteSqlStatementsAsync(dbContext, new[] + { + """ + CREATE TABLE IF NOT EXISTS MerchantRunRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantRunRecords PRIMARY KEY AUTOINCREMENT, + RunId TEXT NOT NULL, + MerchantId TEXT NOT NULL, + MerchantName TEXT NULL, + ScheduledRunUtc TEXT NOT NULL, + Status TEXT NOT NULL, + ErrorMessage TEXT NULL, + CompletedUtc TEXT NOT NULL + ); """, """ CREATE TABLE IF NOT EXISTS MerchantProcessingContractDefinitions (