diff --git a/README.md b/README.md
index b941e4fc..ea986fdd 100644
--- a/README.md
+++ b/README.md
@@ -45,8 +45,9 @@ You can customize the values of the helm deployment by using the following Value
| `image.tag` | Container image tag | `Same as chart version` |
| `image.pullPolicy` | Container image pull policy | `IfNotPresent` |
| `configuration.logging.minimumLevel` | Logging minimum level | `Information` |
-| `configuration.watcher.timeout` | Maximum watcher lifetime in seconds | `` |
-| `configuration.kubernetes.skipTlsVerify` | Skip TLS verify when connecting the the cluster | `false` |
+| `configuration.watcher.timeout` | Maximum watcher lifetime in seconds | `` |
+| `configuration.watcher.excludedNamespaces` | Comma-separated list of namespace glob patterns to exclude from reflection processing. Supports `*` (any characters) and `?` (single character). Example: `"ephie-*,kube-system,*-temp"` | `` |
+| `configuration.kubernetes.skipTlsVerify` | Skip TLS verify when connecting the the cluster | `false` |
| `rbac.enabled` | Create and use RBAC resources | `true` |
| `serviceAccount.create` | Create ServiceAccount | `true` |
| `serviceAccount.name` | ServiceAccount name | _release name_ |
diff --git a/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs b/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs
index 30fd9e11..80314e1f 100644
--- a/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs
+++ b/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs
@@ -3,4 +3,11 @@
public class WatcherOptions
{
public int? Timeout { get; set; }
+
+ ///
+ /// Comma-separated list of namespace patterns to exclude from reflection processing.
+ /// Supports glob wildcards: * (any characters), ? (single character).
+ /// Example: "ephie-*,kube-system,*-temp"
+ ///
+ public string? ExcludedNamespaces { get; set; }
}
\ No newline at end of file
diff --git a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj
index c0b0f6ee..93b96f3f 100644
--- a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj
+++ b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj
@@ -23,4 +23,4 @@
-
\ No newline at end of file
+
diff --git a/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs b/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs
new file mode 100644
index 00000000..a5ea7d58
--- /dev/null
+++ b/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs
@@ -0,0 +1,25 @@
+using System.Text.RegularExpressions;
+
+namespace ES.Kubernetes.Reflector.Watchers.Core;
+
+internal static class GlobMatcher
+{
+ internal static Regex[] ParseGlobPatterns(string? patterns)
+ {
+ if (string.IsNullOrWhiteSpace(patterns)) return [];
+
+ return
+ [
+ .. patterns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(p => new Regex(
+ "^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$",
+ RegexOptions.Compiled))
+ ];
+ }
+
+ internal static bool IsNamespaceExcluded(string? ns, Regex[] patterns)
+ {
+ if (patterns.Length == 0 || string.IsNullOrEmpty(ns)) return false;
+ return patterns.Any(p => p.IsMatch(ns));
+ }
+}
diff --git a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs
index 40f556bb..83abf4fc 100644
--- a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs
+++ b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs
@@ -36,9 +36,20 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
FullMode = BoundedChannelFullMode.Wait
});
+ // Kubernetes namespace names must be valid DNS-1123 labels, which are lowercase-only,
+ // so normalizing the configured exclusion patterns to lowercase ensures comparisons
+ // against Metadata.NamespaceProperty are consistent without changing semantics.
+ var excludedNamespacePatterns = GlobMatcher.ParseGlobPatterns(options.CurrentValue.Watcher?.ExcludedNamespaces?.ToLowerInvariant());
+ long namespaceExcludedCount = 0;
+
try
{
- logger.LogInformation("Requesting {type} resources", typeof(TResource).Name);
+ if (excludedNamespacePatterns.Length > 0)
+ logger.LogInformation(
+ "Requesting {type} resources (excluding namespaces matching: {patterns})",
+ typeof(TResource).Name, options.CurrentValue.Watcher?.ExcludedNamespaces);
+ else
+ logger.LogInformation("Requesting {type} resources", typeof(TResource).Name);
//Read using a separate task so the watcher doesn't get stuck waiting on subscribers to handle the event
_ = Task.Run(async () =>
@@ -62,6 +73,15 @@ await watcherEventHandler.Handle(new WatcherEvent
{
await foreach (var (type, item) in watchList)
{
+ // For cluster-scoped resources like V1Namespace, Metadata.NamespaceProperty is null,
+ // so this exclusion check intentionally becomes a no-op and namespace events
+ // continue flowing to support auto-reflection on new namespace creation.
+ if (GlobMatcher.IsNamespaceExcluded(item.Metadata?.NamespaceProperty, excludedNamespacePatterns))
+ {
+ namespaceExcludedCount++;
+ continue;
+ }
+
if (await OnResourceIgnoreCheck(item)) continue;
await eventChannel.Writer.WriteAsync(new WatcherEvent
{
@@ -91,8 +111,13 @@ await eventChannel.Writer.WriteAsync(new WatcherEvent
var sessionElapsed = sessionStopwatch.Elapsed;
sessionStopwatch.Stop();
- logger.LogInformation("Session closed. Duration: {duration}. Faulted: {faulted}.", sessionElapsed,
- sessionFaulted);
+ if (namespaceExcludedCount > 0)
+ logger.LogInformation(
+ "Session closed. Duration: {duration}. Faulted: {faulted}. Namespace-excluded events: {excluded}.",
+ sessionElapsed, sessionFaulted, namespaceExcludedCount);
+ else
+ logger.LogInformation("Session closed. Duration: {duration}. Faulted: {faulted}.",
+ sessionElapsed, sessionFaulted);
foreach (var handler in watcherClosedHandlers)
await handler.Handle(new WatcherClosed
@@ -107,4 +132,5 @@ await handler.Handle(new WatcherClosed
protected abstract IAsyncEnumerable<(WatchEventType, TResource)> OnGetWatcher(CancellationToken cancellationToken);
protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false);
-}
\ No newline at end of file
+
+}
diff --git a/src/helm/reflector/templates/cron.yaml b/src/helm/reflector/templates/cron.yaml
index 3a6506ea..19631485 100644
--- a/src/helm/reflector/templates/cron.yaml
+++ b/src/helm/reflector/templates/cron.yaml
@@ -68,6 +68,8 @@ spec:
value: {{ .Values.configuration.logging.minimumLevel | quote }}
- name: ES_Reflector__Watcher__Timeout
value: {{ .Values.configuration.watcher.timeout | quote }}
+ - name: ES_Reflector__Watcher__ExcludedNamespaces
+ value: {{ .Values.configuration.watcher.excludedNamespaces | quote }}
- name: ES_Ignite__KubernetesClient__SkipTlsVerify
value: {{ .Values.configuration.kubernetes.skipTlsVerify | quote }}
{{- with .Values.extraEnv }}
diff --git a/src/helm/reflector/templates/deployment.yaml b/src/helm/reflector/templates/deployment.yaml
index f6d5910a..423ca2fe 100644
--- a/src/helm/reflector/templates/deployment.yaml
+++ b/src/helm/reflector/templates/deployment.yaml
@@ -49,6 +49,8 @@ spec:
value: {{ .Values.configuration.logging.minimumLevel | quote }}
- name: ES_Reflector__Watcher__Timeout
value: {{ .Values.configuration.watcher.timeout | quote }}
+ - name: ES_Reflector__Watcher__ExcludedNamespaces
+ value: {{ .Values.configuration.watcher.excludedNamespaces | quote }}
- name: ES_Ignite__KubernetesClient__SkipTlsVerify
value: {{ .Values.configuration.kubernetes.skipTlsVerify | quote }}
{{- with .Values.extraEnv }}
diff --git a/src/helm/reflector/values.yaml b/src/helm/reflector/values.yaml
index 14f9b78b..bb5cbc92 100644
--- a/src/helm/reflector/values.yaml
+++ b/src/helm/reflector/values.yaml
@@ -28,6 +28,10 @@ configuration:
minimumLevel: Information
watcher:
timeout: ""
+ # Comma-separated list of namespace glob patterns to exclude from reflection processing.
+ # Supports wildcards: * (any characters), ? (single character).
+ # Example: "kube-system,*-suffix,prefix-*"
+ excludedNamespaces: ""
kubernetes:
skipTlsVerify: false
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ExcludedNamespacesReflectorFixture.cs b/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ExcludedNamespacesReflectorFixture.cs
new file mode 100644
index 00000000..e4e837a7
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ExcludedNamespacesReflectorFixture.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+
+namespace ES.Kubernetes.Reflector.Tests.Fixtures;
+
+public sealed class ExcludedNamespacesReflectorFixture : ReflectorFixture
+{
+ public const string ExcludedPattern = "excluded-*";
+
+ protected override void ConfigureAdditionalWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["Reflector:Watcher:ExcludedNamespaces"] = ExcludedPattern
+ });
+ });
+ }
+}
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ReflectorFixture.cs b/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ReflectorFixture.cs
index e161d2be..cfafdff8 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ReflectorFixture.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/Fixtures/ReflectorFixture.cs
@@ -6,7 +6,7 @@
namespace ES.Kubernetes.Reflector.Tests.Fixtures;
-public sealed class ReflectorFixture : WebApplicationFactory, IAsyncLifetime
+public class ReflectorFixture : WebApplicationFactory, IAsyncLifetime
{
private static readonly Lock Lock = new();
@@ -43,5 +43,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
return config;
});
});
+ ConfigureAdditionalWebHost(builder);
}
+
+ protected virtual void ConfigureAdditionalWebHost(IWebHostBuilder builder) { }
}
\ No newline at end of file
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs
index 28c14cc1..77199b53 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs
@@ -8,7 +8,7 @@
namespace ES.Kubernetes.Reflector.Tests.Integration.Base;
-public class BaseIntegrationTest(ReflectorIntegrationFixture integrationFixture)
+public class BaseIntegrationTest(IKubernetesIntegrationFixture integrationFixture)
{
protected static readonly ResiliencePipeline ResourceExistsResiliencePipeline =
new ResiliencePipelineBuilder()
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs
new file mode 100644
index 00000000..6cb3167c
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs
@@ -0,0 +1,141 @@
+using System.Net;
+using ES.Kubernetes.Reflector.Tests.Additions;
+using ES.Kubernetes.Reflector.Tests.Fixtures;
+using ES.Kubernetes.Reflector.Tests.Integration.Base;
+using ES.Kubernetes.Reflector.Tests.Integration.Fixtures;
+using JetBrains.Annotations;
+using k8s;
+using k8s.Autorest;
+using k8s.Models;
+
+[assembly: AssemblyFixture(typeof(ExcludedNamespacesIntegrationFixture))]
+
+namespace ES.Kubernetes.Reflector.Tests.Integration;
+
+[PublicAPI]
+public class ExcludedNamespacesIntegrationTests(
+ ExcludedNamespacesIntegrationFixture integrationFixture)
+ : BaseIntegrationTest(integrationFixture)
+{
+ [Fact]
+ public async Task WatchEvents_FromExcludedNamespace_AreNotReflected()
+ {
+ var client = await GetKubernetesClient();
+
+ var excludedNamespace = $"excluded-{Guid.CreateVersion7()}";
+ var allowedNamespace = $"allowed-{Guid.CreateVersion7()}";
+ var targetNamespace = $"target-{Guid.CreateVersion7()}";
+
+ await CreateNamespaceAsync(excludedNamespace);
+ await CreateNamespaceAsync(allowedNamespace);
+ await CreateNamespaceAsync(targetNamespace);
+
+ // Resource in excluded namespace — should not be mirrored anywhere
+ var excludedSourceResource = await CreateResource(client, namespaceName: excludedNamespace,
+ annotations: new ReflectorAnnotationsBuilder()
+ .WithReflectionAllowed(true)
+ .WithAutoEnabled(true).Build());
+
+ // Resource in allowed namespace — should cross-namespace auto-reflect into targetNamespace
+ var allowedSourceResource = await CreateResource(client, namespaceName: allowedNamespace,
+ annotations: new ReflectorAnnotationsBuilder()
+ .WithReflectionAllowed(true)
+ .WithAllowedNamespaces("^target-.*")
+ .WithAutoEnabled(true).Build());
+
+ await DelayForReflection();
+
+ // The excluded-namespace resource should not have triggered auto-reflection into any other namespace
+ Assert.False(await ResourceExists(client,
+ excludedSourceResource.Name(), targetNamespace,
+ TestContext.Current.CancellationToken));
+
+ // The allowed-namespace resource should cross-namespace reflect into targetNamespace
+ Assert.True(await WaitForResource(client,
+ allowedSourceResource.Name(), targetNamespace,
+ TestContext.Current.CancellationToken));
+ }
+
+
+ [Fact]
+ public async Task AutoReflect_IntoExcludedTargetNamespace_StillReflects()
+ {
+ // The exclusion filter drops watch events FROM excluded namespaces (source-side filtering
+ // via item.Metadata.NamespaceProperty). Namespace objects are cluster-scoped so their
+ // events are never filtered, meaning excluded namespaces stay in the namespace cache and
+ // remain valid auto-reflection targets. This test pins down that behavior.
+ var client = await GetKubernetesClient();
+
+ var sourceNamespace = $"allowed-{Guid.CreateVersion7()}";
+ var excludedTargetNamespace = $"excluded-{Guid.CreateVersion7()}";
+
+ await CreateNamespaceAsync(sourceNamespace);
+ await CreateNamespaceAsync(excludedTargetNamespace);
+
+ var sourceResource = await CreateResource(client, namespaceName: sourceNamespace,
+ annotations: new ReflectorAnnotationsBuilder()
+ .WithReflectionAllowed(true)
+ .WithAutoEnabled(true).Build());
+
+ await DelayForReflection();
+
+ Assert.True(await WaitForResource(client,
+ sourceResource.Name(), excludedTargetNamespace,
+ TestContext.Current.CancellationToken));
+ }
+
+ private async Task CreateResource(IKubernetes client,
+ string? name = null, string? namespaceName = null,
+ Dictionary? annotations = null)
+ {
+ namespaceName ??= Guid.CreateVersion7().ToString();
+ var sourceResource = new V1Secret
+ {
+ ApiVersion = V1Secret.KubeApiVersion,
+ Kind = V1Secret.KubeKind,
+ Metadata = new V1ObjectMeta
+ {
+ Name = name ?? Guid.CreateVersion7().ToString(),
+ NamespaceProperty = namespaceName,
+ Annotations = annotations ?? new ReflectorAnnotationsBuilder().Build()
+ },
+ StringData = new Dictionary
+ {
+ { Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }
+ },
+ Type = "Opaque"
+ };
+
+ var namespaces = await client.CoreV1.ListNamespaceAsync();
+ if (!namespaces.Items.Any(s => s.Name() == namespaceName))
+ await CreateNamespaceAsync(namespaceName);
+
+ return await client.CoreV1.CreateNamespacedSecretAsync(sourceResource, namespaceName);
+ }
+
+ private async Task WaitForResource(IKubernetes client, string name, string namespaceName,
+ CancellationToken cancellationToken = default)
+ {
+ return await ResourceExistsResiliencePipeline.ExecuteAsync(async token =>
+ {
+ var resource = await client.CoreV1.ReadNamespacedSecretAsync(
+ name, namespaceName, cancellationToken: token);
+ return resource is not null;
+ }, cancellationToken);
+ }
+
+ private async Task ResourceExists(IKubernetes client, string name, string namespaceName,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await client.CoreV1.ReadNamespacedSecretAsync(name, namespaceName,
+ cancellationToken: TestContext.Current.CancellationToken);
+ return true;
+ }
+ catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
+ {
+ return false;
+ }
+ }
+}
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ExcludedNamespacesIntegrationFixture.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ExcludedNamespacesIntegrationFixture.cs
new file mode 100644
index 00000000..5c03d2f3
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ExcludedNamespacesIntegrationFixture.cs
@@ -0,0 +1,20 @@
+using ES.Kubernetes.Reflector.Tests.Fixtures;
+
+namespace ES.Kubernetes.Reflector.Tests.Integration.Fixtures;
+
+public class ExcludedNamespacesIntegrationFixture : IAsyncLifetime, IKubernetesIntegrationFixture
+{
+ public KubernetesFixture Kubernetes { get; init; } = new();
+ public ExcludedNamespacesReflectorFixture Reflector { get; init; } = new();
+
+ public async ValueTask InitializeAsync()
+ {
+ await Kubernetes.InitializeAsync();
+ Reflector.KubernetesClientConfiguration =
+ await Kubernetes.GetKubernetesClientConfiguration();
+ await Reflector.InitializeAsync();
+ Reflector.CreateClient();
+ }
+
+ public async ValueTask DisposeAsync() => await Task.CompletedTask;
+}
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/IKubernetesIntegrationFixture.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/IKubernetesIntegrationFixture.cs
new file mode 100644
index 00000000..8dfe4c50
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/IKubernetesIntegrationFixture.cs
@@ -0,0 +1,8 @@
+using ES.Kubernetes.Reflector.Tests.Fixtures;
+
+namespace ES.Kubernetes.Reflector.Tests.Integration.Fixtures;
+
+public interface IKubernetesIntegrationFixture
+{
+ KubernetesFixture Kubernetes { get; }
+}
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ReflectorIntegrationFixture.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ReflectorIntegrationFixture.cs
index e68b7777..ebc39771 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ReflectorIntegrationFixture.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ReflectorIntegrationFixture.cs
@@ -2,7 +2,7 @@
namespace ES.Kubernetes.Reflector.Tests.Integration.Fixtures;
-public class ReflectorIntegrationFixture : IAsyncLifetime
+public class ReflectorIntegrationFixture : IAsyncLifetime, IKubernetesIntegrationFixture
{
public KubernetesFixture Kubernetes { get; init; } = new();
public ReflectorFixture Reflector { get; init; } = new();
diff --git a/tests/ES.Kubernetes.Reflector.Tests/Unit/GlobMatcherTests.cs b/tests/ES.Kubernetes.Reflector.Tests/Unit/GlobMatcherTests.cs
new file mode 100644
index 00000000..b1ff6d01
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/Unit/GlobMatcherTests.cs
@@ -0,0 +1,172 @@
+using ES.Kubernetes.Reflector.Watchers.Core;
+
+namespace ES.Kubernetes.Reflector.Tests.Unit;
+
+public class GlobMatcherTests
+{
+ // ParseGlobPatterns
+
+ [Fact]
+ public void ParseGlobPatterns_NullInput_ReturnsEmpty()
+ {
+ var result = GlobMatcher.ParseGlobPatterns(null);
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ParseGlobPatterns_EmptyString_ReturnsEmpty()
+ {
+ var result = GlobMatcher.ParseGlobPatterns("");
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ParseGlobPatterns_WhitespaceOnly_ReturnsEmpty()
+ {
+ var result = GlobMatcher.ParseGlobPatterns(" ");
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void ParseGlobPatterns_SinglePattern_ReturnsSingleRegex()
+ {
+ var result = GlobMatcher.ParseGlobPatterns("kube-system");
+ Assert.Single(result);
+ }
+
+ [Fact]
+ public void ParseGlobPatterns_MultiplePatterns_ReturnsMultipleRegexes()
+ {
+ var result = GlobMatcher.ParseGlobPatterns("kube-system,kube-public,default");
+ Assert.Equal(3, result.Length);
+ }
+
+ [Fact]
+ public void ParseGlobPatterns_PatternWithWhitespace_TrimsEntries()
+ {
+ var result = GlobMatcher.ParseGlobPatterns(" kube-system , kube-public ");
+ Assert.Equal(2, result.Length);
+ }
+
+ [Fact]
+ public void ParseGlobPatterns_EmptyEntriesInList_IgnoresEmpty()
+ {
+ var result = GlobMatcher.ParseGlobPatterns("kube-system,,kube-public");
+ Assert.Equal(2, result.Length);
+ }
+
+ // IsNamespaceExcluded — empty patterns
+
+ [Fact]
+ public void IsNamespaceExcluded_EmptyPatterns_ReturnsFalse()
+ {
+ Assert.False(GlobMatcher.IsNamespaceExcluded("kube-system", []));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_NullNamespace_ReturnsFalse()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("kube-*");
+ Assert.False(GlobMatcher.IsNamespaceExcluded(null, patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_EmptyNamespace_ReturnsFalse()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("kube-*");
+ Assert.False(GlobMatcher.IsNamespaceExcluded("", patterns));
+ }
+
+ // IsNamespaceExcluded — exact match
+
+ [Fact]
+ public void IsNamespaceExcluded_ExactMatch_ReturnsTrue()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("kube-system");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("kube-system", patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_NoMatch_ReturnsFalse()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("kube-system");
+ Assert.False(GlobMatcher.IsNamespaceExcluded("default", patterns));
+ }
+
+ // IsNamespaceExcluded — star wildcard
+
+ [Fact]
+ public void IsNamespaceExcluded_StarWildcard_MatchesPrefix()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("ephie-*");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("ephie-pr-123", patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_StarAlone_MatchesAnyNamespace()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("*");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("any-namespace", patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_StarWildcard_DoesNotMatchDifferentPrefix()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("ephie-*");
+ Assert.False(GlobMatcher.IsNamespaceExcluded("prod-namespace", patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_StarWildcard_MatchesSuffix()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("*-temp");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("feature-temp", patterns));
+ Assert.False(GlobMatcher.IsNamespaceExcluded("feature-prod", patterns));
+ }
+
+ // IsNamespaceExcluded — question mark wildcard
+
+ [Fact]
+ public void IsNamespaceExcluded_QuestionMarkWildcard_MatchesSingleChar()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("ns-?");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("ns-a", patterns));
+ Assert.True(GlobMatcher.IsNamespaceExcluded("ns-1", patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_QuestionMarkWildcard_DoesNotMatchMultipleChars()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("ns-?");
+ Assert.False(GlobMatcher.IsNamespaceExcluded("ns-ab", patterns));
+ }
+
+ // IsNamespaceExcluded — multiple patterns
+
+ [Fact]
+ public void IsNamespaceExcluded_MultiplePatterns_MatchesAny()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("kube-system,kube-public");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("kube-system", patterns));
+ Assert.True(GlobMatcher.IsNamespaceExcluded("kube-public", patterns));
+ Assert.False(GlobMatcher.IsNamespaceExcluded("default", patterns));
+ }
+
+ // IsNamespaceExcluded — regex metacharacters in namespace names
+
+ [Fact]
+ public void IsNamespaceExcluded_NamespaceWithDot_MatchesLiterally()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("ns.special");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("ns.special", patterns));
+ Assert.False(GlobMatcher.IsNamespaceExcluded("nsXspecial", patterns));
+ }
+
+ [Fact]
+ public void IsNamespaceExcluded_PatternWithBrackets_MatchesLiterally()
+ {
+ var patterns = GlobMatcher.ParseGlobPatterns("ns[1]");
+ Assert.True(GlobMatcher.IsNamespaceExcluded("ns[1]", patterns));
+ Assert.False(GlobMatcher.IsNamespaceExcluded("ns1", patterns));
+ }
+}