Skip to content
Draft

IBMMQ #5350

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
45b751d
Add IBM MQ transport support, including custom checks and tests
ramonsmits Feb 20, 2026
ad32973
Update IBM MQ transport tests to set static transport concurrency values
ramonsmits Feb 20, 2026
29b7abb
Improve IBM MQ transport customization to support sanitized resource …
ramonsmits Feb 20, 2026
95b2da6
✨ Implement IBM MQ queue length provider
ramonsmits Feb 20, 2026
48c6285
Make queue length test work for IBMMQ transport
ramonsmits Feb 24, 2026
a979c54
📦 Update NServiceBus.Transport.IbmMq to 1.0.0-alpha.1
ramonsmits Mar 9, 2026
f0684ab
⚜️ Update NuGet package ID casing to NServiceBus.Transport.IBMMQ
ramonsmits Mar 9, 2026
048a7ce
Updated nuget.config, contained local folder used during dev testing
ramonsmits Mar 9, 2026
f4ef978
Merge remote-tracking branch 'origin/master' into ibmmq
ramonsmits Mar 9, 2026
7ca78ff
⚙️ Add IBMMQ test category to CI workflow
ramonsmits Mar 9, 2026
019005d
⚙️ Use IBM MQ container with health check instead of secrets
ramonsmits Mar 9, 2026
c6373f2
🐛 Set MQ_ADMIN_PASSWORD for IBM MQ CI container
ramonsmits Mar 9, 2026
33ff6be
🐛 Fix IBMMQ transport deploy folder and update approval tests
ramonsmits Mar 9, 2026
40c89e6
🐛 Add IBMMQ to DevelopmentTransportLocations for manifest discovery
ramonsmits Mar 9, 2026
99ba243
Sort transport manifest folder names
ramonsmits Mar 9, 2026
1be5f5e
Use TryGet instead of Get... could be that no override is registered
ramonsmits Mar 11, 2026
7b6e86a
✨ Reuse MQQueueManager connection in QueueLengthProvider with broker …
ramonsmits Mar 11, 2026
72348d8
⚜️ Remove duplicate CopyToOutputDirectory for transport.manifest
ramonsmits Mar 11, 2026
a4e9d48
✨ Add IBM MQ Dead Letter Queue custom check
ramonsmits Mar 11, 2026
45a04c9
✨ Add tests for IBM MQ DeadLetterQueueCheck
ramonsmits Mar 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
test-category: [ Default, SqlServer, AzureServiceBus, RabbitMQ, AzureStorageQueues, MSMQ, SQS, PrimaryRavenAcceptance, PrimaryRavenPersistence, PostgreSQL ]
test-category: [ Default, SqlServer, AzureServiceBus, RabbitMQ, AzureStorageQueues, MSMQ, SQS, PrimaryRavenAcceptance, PrimaryRavenPersistence, PostgreSQL, IBMMQ ]
include:
- os: windows-latest
os-name: Windows
Expand All @@ -27,6 +27,8 @@ jobs:
exclude:
- os: ubuntu-latest
test-category: MSMQ
- os: windows-latest
test-category: IBMMQ
fail-fast: false
steps:
- name: Check for secrets
Expand Down Expand Up @@ -103,6 +105,18 @@ jobs:
connection-string-name: ServiceControl_TransportTests_ASQ_ConnectionString
azure-credentials: ${{ secrets.AZURE_ACI_CREDENTIALS }}
tag: ServiceControl
- name: Setup IBM MQ
if: matrix.test-category == 'IBMMQ'
run: |
docker run --name ibmmq -d -p 1414:1414 -p 9443:9443 `
--health-cmd "dspmq" --health-interval 10s --health-timeout 5s --health-retries 10 --health-start-period 30s `
-e LICENSE=accept -e MQ_QMGR_NAME=QM1 -e MQ_ADMIN_PASSWORD=passw0rd `
icr.io/ibm-messaging/mq:latest
# Wait for container health check to pass
while ((docker inspect --format '{{.State.Health.Status}}' ibmmq) -ne 'healthy') {
Start-Sleep -Seconds 2
}
echo "ServiceControl_TransportTests_IBMMQ_ConnectionString=mq://admin:passw0rd@localhost:1414/QM1?channel=DEV.ADMIN.SVRCONN&topicprefix=DEV" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf-8 -Append
- name: Setup SQS environment variables
if: matrix.test-category == 'SQS'
run: |
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
<PackageVersion Include="NServiceBus.Transport.Msmq.Sources" Version="4.0.0" />
<PackageVersion Include="NServiceBus.Transport.PostgreSql" Version="9.0.0" />
<PackageVersion Include="NServiceBus.Transport.SqlServer" Version="9.0.0" />
<PackageVersion Include="NServiceBus.Transport.IBMMQ" Version="1.0.0-alpha.1" />
<PackageVersion Include="NuGet.Versioning" Version="7.3.0" />
<PackageVersion Include="NUnit" Version="4.5.1" />
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
Expand Down
148 changes: 148 additions & 0 deletions src/ServiceControl.Transports.IBMMQ.Tests/DeadLetterQueueCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
namespace ServiceControl.Transport.Tests;

using System;
using System.Collections;
using System.Threading.Tasks;
using System.Web;
using IBM.WMQ;
using NUnit.Framework;
using Transports;
using Transports.IBMMQ;

[TestFixture]
class DeadLetterQueueCheckTests
{
[Test]
public async Task Should_pass_when_custom_checks_disabled()
{
var settings = new TransportSettings
{
ConnectionString = ConnectionString,
RunCustomChecks = false
};

var check = new DeadLetterQueueCheck(settings);
var result = await check.PerformCheck().ConfigureAwait(false);

Assert.That(result.HasFailed, Is.False);
}

[Test]
public async Task Should_pass_when_dlq_is_empty()
{
DrainDeadLetterQueue();

var settings = new TransportSettings
{
ConnectionString = ConnectionString,
RunCustomChecks = true
};

var check = new DeadLetterQueueCheck(settings);
var result = await check.PerformCheck().ConfigureAwait(false);

Assert.That(result.HasFailed, Is.False);
}

[Test]
public async Task Should_fail_when_dlq_has_messages()
{
DrainDeadLetterQueue();
PutMessageOnDeadLetterQueue();

try
{
var settings = new TransportSettings
{
ConnectionString = ConnectionString,
RunCustomChecks = true
};

var check = new DeadLetterQueueCheck(settings);
var result = await check.PerformCheck().ConfigureAwait(false);

Assert.That(result.HasFailed, Is.True);
Assert.That(result.FailureReason, Does.Contain("messages in the Dead Letter Queue"));
}
finally
{
DrainDeadLetterQueue();
}
}

[Test]
public async Task Should_fail_when_connection_is_invalid()
{
var settings = new TransportSettings
{
ConnectionString = "mq://admin:passw0rd@localhost:19999/BOGUS",
RunCustomChecks = true
};

var check = new DeadLetterQueueCheck(settings);
var result = await check.PerformCheck().ConfigureAwait(false);

Assert.That(result.HasFailed, Is.True);
Assert.That(result.FailureReason, Does.Contain("Unable to check Dead Letter Queue"));
Assert.That(result.FailureReason, Does.Contain("RC="));
}

static void PutMessageOnDeadLetterQueue()
{
var (qmName, props) = ParseConnectionString();
using var qm = new MQQueueManager(qmName, props);
var dlqName = qm.DeadLetterQueueName.Trim();
using var dlq = qm.AccessQueue(dlqName, MQC.MQOO_OUTPUT);
var msg = new MQMessage();
msg.WriteString("DLQ test message");
dlq.Put(msg);
}

static void DrainDeadLetterQueue()
{
var (qmName, props) = ParseConnectionString();
using var qm = new MQQueueManager(qmName, props);
var dlqName = qm.DeadLetterQueueName.Trim();
using var dlq = qm.AccessQueue(dlqName, MQC.MQOO_INPUT_SHARED | MQC.MQOO_FAIL_IF_QUIESCING);
var gmo = new MQGetMessageOptions { WaitInterval = 0, Options = MQC.MQGMO_NO_WAIT };
while (true)
{
try
{
dlq.Get(new MQMessage(), gmo);
}
catch (MQException e) when (e.ReasonCode == MQC.MQRC_NO_MSG_AVAILABLE)
{
break;
}
}
}

static (string queueManagerName, Hashtable properties) ParseConnectionString()
{
var uri = new Uri(ConnectionString);
var query = HttpUtility.ParseQueryString(uri.Query);

var qmName = uri.AbsolutePath.Trim('/') is { Length: > 0 } path
? Uri.UnescapeDataString(path)
: "QM1";

var props = new Hashtable
{
[MQC.TRANSPORT_PROPERTY] = MQC.TRANSPORT_MQSERIES_MANAGED,
[MQC.HOST_NAME_PROPERTY] = uri.Host,
[MQC.PORT_PROPERTY] = uri.Port > 0 ? uri.Port : 1414,
[MQC.CHANNEL_PROPERTY] = query["channel"] ?? "DEV.ADMIN.SVRCONN",
[MQC.USE_MQCSP_AUTHENTICATION_PROPERTY] = true,
[MQC.USER_ID_PROPERTY] = Uri.UnescapeDataString(uri.UserInfo.Split(':')[0]),
[MQC.PASSWORD_PROPERTY] = Uri.UnescapeDataString(uri.UserInfo.Split(':')[1])
};

return (qmName, props);
}

static readonly string ConnectionString =
Environment.GetEnvironmentVariable("ServiceControl_TransportTests_IBMMQ_ConnectionString")
?? Environment.GetEnvironmentVariable("SERVICECONTROL_TRANSPORTTESTS_IBMMQ_CONNECTIONSTRING")
?? "mq://admin:passw0rd@localhost:1414";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\ServiceControl.Transports.IBMMQ\ServiceControl.Transports.IBMMQ.csproj" />
<!-- Needed to bring the dependencies that the transport plugin excludes -->
<ProjectReference Include="..\ServiceControl.Transports\ServiceControl.Transports.csproj" />
<ProjectReference Include="..\TestHelper\TestHelper.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="GitHubActionsTestLogger" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NServiceBus.AcceptanceTesting" />
<PackageReference Include="NServiceBus.Persistence.NonDurable" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Particular.Approvals" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\ServiceControl.Transports.Tests\*.cs" LinkBase="Shared" />
<Compile Remove="..\ServiceControl.Transports.Tests\TransportManifestLibraryTests.cs" />
<Compile Remove="..\ServiceControl.Transports.Tests\TestsFilter.cs" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ServiceControl.Transport.Tests;

using System;

partial class ServiceControlAuditEndpointTests
{
private static partial int GetTransportDefaultConcurrency() => 32;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ServiceControl.Transport.Tests;

using System;

partial class ServiceControlMonitoringEndpointTests
{
private static partial int GetTransportDefaultConcurrency() => 32;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ServiceControl.Transport.Tests;

using System;

partial class ServiceControlPrimaryEndpointTests
{
private static partial int GetTransportDefaultConcurrency() => 10;
}
1 change: 1 addition & 0 deletions src/ServiceControl.Transports.IBMMQ.Tests/TestsFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[assembly: IncludeInIBMMQTests()]
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace ServiceControl.Transport.Tests;

using System;
using System.Threading.Tasks;
using Transports;
using Transports.IBMMQ;
using NServiceBus;
using NServiceBus.Transport.IBMMQ;
using NUnit.Framework;

[SetUpFixture]
public class BootstrapFixture
{
[OneTimeSetUp]
public void RunBeforeAnyTests() => TransportTestFixture.QueueNameSeparator = '.';
}

class TransportTestsConfiguration
{
public string ConnectionString { get; private set; }

public ITransportCustomization TransportCustomization { get; private set; }

public Task Configure()
{
TransportCustomization = new TestIBMMQTransportCustomization();
ConnectionString = Environment.GetEnvironmentVariable(ConnectionStringKey)
?? Environment.GetEnvironmentVariable(ConnectionStringKey.ToUpperInvariant()); // Env keys are case sensitive, POSIX is all uppercase

if (string.IsNullOrEmpty(ConnectionString))
{
throw new Exception($"Environment variable {ConnectionStringKey} is required for IBM MQ transport tests to run");
}

return Task.CompletedTask;
}

public Task Cleanup() => Task.CompletedTask;

static string ConnectionStringKey = "ServiceControl_TransportTests_IBMMQ_ConnectionString";
}

sealed class TestIBMMQTransportCustomization : IBMMQTransportCustomization
{
protected override IBMMQTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly)
{
transportSettings.Set<Action<IBMMQTransportOptions>>(o => o.ResourceNameSanitizer = name => name
.Replace("ServiceControlMonitoring", "SCM") // Mitigate max queue name length
.Replace("-", ".") // dash is an illegal char
);
return base.CreateTransport(transportSettings, preferredTransactionMode);
}
}
66 changes: 66 additions & 0 deletions src/ServiceControl.Transports.IBMMQ/ConnectionProperties.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
namespace ServiceControl.Transports.IBMMQ;

using System;
using System.Collections;
using System.Web;
using IBM.WMQ;

static class ConnectionProperties
{
public static (string queueManagerName, Hashtable properties) Parse(string connectionString)
{
var connectionUri = new Uri(connectionString);
var query = HttpUtility.ParseQueryString(connectionUri.Query);

var queueManagerName = connectionUri.AbsolutePath.Trim('/') is { Length: > 0 } path
? Uri.UnescapeDataString(path)
: "QM1";

var properties = new Hashtable
{
[MQC.TRANSPORT_PROPERTY] = MQC.TRANSPORT_MQSERIES_MANAGED,
[MQC.HOST_NAME_PROPERTY] = connectionUri.Host,
[MQC.PORT_PROPERTY] = connectionUri.Port > 0 ? connectionUri.Port : 1414,
[MQC.CHANNEL_PROPERTY] = query["channel"] ?? "DEV.ADMIN.SVRCONN"
};

var userInfo = connectionUri.UserInfo;
if (!string.IsNullOrEmpty(userInfo))
{
var parts = userInfo.Split(':');
var user = Uri.UnescapeDataString(parts[0]);

if (!string.IsNullOrWhiteSpace(user))
{
properties[MQC.USE_MQCSP_AUTHENTICATION_PROPERTY] = true;
properties[MQC.USER_ID_PROPERTY] = user;
}

if (parts.Length > 1)
{
var password = Uri.UnescapeDataString(parts[1]);
if (!string.IsNullOrWhiteSpace(password))
{
properties[MQC.PASSWORD_PROPERTY] = password;
}
}
}

if (query["sslkeyrepo"] is { } sslKeyRepo)
{
properties[MQC.SSL_CERT_STORE_PROPERTY] = sslKeyRepo;
}

if (query["cipherspec"] is { } cipherSpec)
{
properties[MQC.SSL_CIPHER_SPEC_PROPERTY] = cipherSpec;
}

if (query["sslpeername"] is { } sslPeerName)
{
properties[MQC.SSL_PEER_NAME_PROPERTY] = sslPeerName;
}

return (queueManagerName, properties);
}
}
Loading