UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization
Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
@@ -363,6 +363,7 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
Name = _options.DurableConsumer,
|
||||
DurableName = _options.DurableConsumer,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
|
||||
@@ -373,6 +374,23 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
|
||||
FilterSubjects = new[] { _options.Subject }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
_options.Stream,
|
||||
_options.DurableConsumer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return _consumer;
|
||||
}
|
||||
catch (NatsJSApiException apiEx) when (IsConsumerNotFound(apiEx))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"Durable consumer {Durable} not found; creating new consumer.",
|
||||
_options.DurableConsumer);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.CreateConsumerAsync(
|
||||
@@ -381,12 +399,11 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException apiEx)
|
||||
catch (NatsJSApiException apiEx) when (IsConsumerAlreadyExists(apiEx))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
|
||||
apiEx.Error?.Code,
|
||||
"Consumer {Durable} already exists; fetching existing durable consumer.",
|
||||
_options.DurableConsumer);
|
||||
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
@@ -444,7 +461,7 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
|
||||
{
|
||||
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
catch (NatsJSApiException ex) when (IsStreamNotFound(ex))
|
||||
{
|
||||
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
|
||||
{
|
||||
@@ -466,7 +483,7 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
|
||||
{
|
||||
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
catch (NatsJSApiException ex) when (IsStreamNotFound(ex))
|
||||
{
|
||||
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
|
||||
{
|
||||
@@ -688,6 +705,43 @@ internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisp
|
||||
private static long ToNanoseconds(TimeSpan value)
|
||||
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
|
||||
|
||||
private static bool IsStreamNotFound(NatsJSApiException ex)
|
||||
{
|
||||
var code = ex.Error?.Code ?? 0;
|
||||
if (code is 404 or 10059)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = ex.Error?.Description ?? ex.Message;
|
||||
return message.Contains("stream not found", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsConsumerNotFound(NatsJSApiException ex)
|
||||
{
|
||||
var code = ex.Error?.Code ?? 0;
|
||||
if (code is 404 or 10014)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = ex.Error?.Description ?? ex.Message;
|
||||
return message.Contains("consumer not found", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsConsumerAlreadyExists(NatsJSApiException ex)
|
||||
{
|
||||
var code = ex.Error?.Code ?? 0;
|
||||
if (code == 10013)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = ex.Error?.Description ?? ex.Message;
|
||||
return message.Contains("consumer already exists", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("consumer name already in use", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
|
||||
@@ -371,6 +371,7 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
Name = _options.DurableConsumer,
|
||||
DurableName = _options.DurableConsumer,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
|
||||
@@ -381,6 +382,23 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
FilterSubjects = new[] { _options.Subject }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
_options.Stream,
|
||||
_options.DurableConsumer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return _consumer;
|
||||
}
|
||||
catch (NatsJSApiException apiEx) when (IsConsumerNotFound(apiEx))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"Durable consumer {Durable} not found; creating new consumer.",
|
||||
_options.DurableConsumer);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.CreateConsumerAsync(
|
||||
@@ -389,12 +407,11 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException apiEx)
|
||||
catch (NatsJSApiException apiEx) when (IsConsumerAlreadyExists(apiEx))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
|
||||
apiEx.Error?.Code,
|
||||
"Consumer {Durable} already exists; fetching existing durable consumer.",
|
||||
_options.DurableConsumer);
|
||||
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
@@ -452,7 +469,7 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
{
|
||||
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
catch (NatsJSApiException ex) when (IsStreamNotFound(ex))
|
||||
{
|
||||
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
|
||||
{
|
||||
@@ -474,7 +491,7 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
{
|
||||
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
catch (NatsJSApiException ex) when (IsStreamNotFound(ex))
|
||||
{
|
||||
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
|
||||
{
|
||||
@@ -689,6 +706,43 @@ internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
private static long ToNanoseconds(TimeSpan value)
|
||||
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
|
||||
|
||||
private static bool IsStreamNotFound(NatsJSApiException ex)
|
||||
{
|
||||
var code = ex.Error?.Code ?? 0;
|
||||
if (code is 404 or 10059)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = ex.Error?.Description ?? ex.Message;
|
||||
return message.Contains("stream not found", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsConsumerNotFound(NatsJSApiException ex)
|
||||
{
|
||||
var code = ex.Error?.Code ?? 0;
|
||||
if (code is 404 or 10014)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = ex.Error?.Description ?? ex.Message;
|
||||
return message.Contains("consumer not found", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsConsumerAlreadyExists(NatsJSApiException ex)
|
||||
{
|
||||
var code = ex.Error?.Code ?? 0;
|
||||
if (code == 10013)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var message = ex.Error?.Description ?? ex.Message;
|
||||
return message.Contains("consumer already exists", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("consumer name already in use", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static class EmptyReadOnlyDictionary<TKey, TValue>
|
||||
where TKey : notnull
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Notify.Models;
|
||||
@@ -1066,7 +1067,7 @@ public sealed class NotificationTemplateRenderer
|
||||
var ifStart = result.IndexOf("{{#if ", StringComparison.Ordinal);
|
||||
if (ifStart < 0) break;
|
||||
|
||||
var condEnd = result.IndexOf("}}", ifStart, StringComparison.Ordinal);
|
||||
var condEnd = FindConditionEnd(result, ifStart);
|
||||
if (condEnd < 0) break;
|
||||
|
||||
var condition = result.Substring(ifStart + 6, condEnd - ifStart - 6).Trim();
|
||||
@@ -1104,6 +1105,11 @@ public sealed class NotificationTemplateRenderer
|
||||
|
||||
private static bool EvaluateCondition(string condition, TemplateContext context)
|
||||
{
|
||||
if (TryEvaluateComparison(condition, context, out var comparisonResult))
|
||||
{
|
||||
return comparisonResult;
|
||||
}
|
||||
|
||||
if (context.Variables.TryGetValue(condition, out var value))
|
||||
{
|
||||
return value switch
|
||||
@@ -1118,6 +1124,184 @@ public sealed class NotificationTemplateRenderer
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int FindConditionEnd(string template, int ifStart)
|
||||
{
|
||||
var index = ifStart + 6;
|
||||
var depth = 0;
|
||||
|
||||
while (index < template.Length - 1)
|
||||
{
|
||||
if (template[index] == '{' && template[index + 1] == '{')
|
||||
{
|
||||
depth++;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (template[index] == '}' && template[index + 1] == '}')
|
||||
{
|
||||
if (depth == 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
depth--;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool TryEvaluateComparison(string condition, TemplateContext context, out bool result)
|
||||
{
|
||||
result = false;
|
||||
var normalized = NormalizeCondition(condition);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TrySplitComparison(normalized, out var leftToken, out var op, out var rightToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var leftValue = ResolveOperand(leftToken, context);
|
||||
var rightValue = ResolveOperand(rightToken, context);
|
||||
|
||||
if (TryCompareNumbers(leftValue, rightValue, op, out result))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (op is "eq" or "neq")
|
||||
{
|
||||
var leftText = leftValue?.ToString() ?? string.Empty;
|
||||
var rightText = rightValue?.ToString() ?? string.Empty;
|
||||
result = op == "eq"
|
||||
? string.Equals(leftText, rightText, StringComparison.Ordinal)
|
||||
: !string.Equals(leftText, rightText, StringComparison.Ordinal);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeCondition(string condition)
|
||||
{
|
||||
var normalized = condition.Trim();
|
||||
if (normalized.StartsWith("(", StringComparison.Ordinal) && normalized.EndsWith(")", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized[1..^1].Trim();
|
||||
}
|
||||
|
||||
return normalized
|
||||
.Replace("{{", "", StringComparison.Ordinal)
|
||||
.Replace("}}", "", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private static bool TrySplitComparison(string condition, out string left, out string op, out string right)
|
||||
{
|
||||
left = string.Empty;
|
||||
op = string.Empty;
|
||||
right = string.Empty;
|
||||
|
||||
var parts = condition.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
left = parts[0];
|
||||
op = parts[1];
|
||||
right = parts[2];
|
||||
return op is "gt" or "gte" or "lt" or "lte" or "eq" or "neq";
|
||||
}
|
||||
|
||||
private static object? ResolveOperand(string token, TemplateContext context)
|
||||
{
|
||||
var trimmed = token.Trim();
|
||||
if (trimmed.Length >= 2 && ((trimmed.StartsWith("\"", StringComparison.Ordinal) && trimmed.EndsWith("\"", StringComparison.Ordinal))
|
||||
|| (trimmed.StartsWith("'", StringComparison.Ordinal) && trimmed.EndsWith("'", StringComparison.Ordinal))))
|
||||
{
|
||||
return trimmed[1..^1];
|
||||
}
|
||||
|
||||
if (bool.TryParse(trimmed, out var boolValue))
|
||||
{
|
||||
return boolValue;
|
||||
}
|
||||
|
||||
if (decimal.TryParse(trimmed, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
{
|
||||
return decimalValue;
|
||||
}
|
||||
|
||||
return ResolvePath(trimmed, context.Variables);
|
||||
}
|
||||
|
||||
private static bool TryCompareNumbers(object? left, object? right, string op, out bool result)
|
||||
{
|
||||
result = false;
|
||||
|
||||
if (!TryConvertToDecimal(left, out var leftNumber) || !TryConvertToDecimal(right, out var rightNumber))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = op switch
|
||||
{
|
||||
"gt" => leftNumber > rightNumber,
|
||||
"gte" => leftNumber >= rightNumber,
|
||||
"lt" => leftNumber < rightNumber,
|
||||
"lte" => leftNumber <= rightNumber,
|
||||
"eq" => leftNumber == rightNumber,
|
||||
"neq" => leftNumber != rightNumber,
|
||||
_ => false
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConvertToDecimal(object? value, out decimal result)
|
||||
{
|
||||
result = 0m;
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value is decimal decimalValue)
|
||||
{
|
||||
result = decimalValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value is IConvertible convertible)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = convertible.ToDecimal(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidCastException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string ProcessLoops(string template, TemplateContext context)
|
||||
{
|
||||
var result = template;
|
||||
|
||||
Reference in New Issue
Block a user