From c358e29b601eff9ab6fd54c1bfbdb5bc93a605e5 Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Thu, 26 Mar 2026 20:47:38 -0400 Subject: [PATCH 1/6] Add excluded namespace patterns for watcher --- .../Configuration/WatcherOptions.cs | 7 +++ .../Watchers/Core/WatcherBackgroundService.cs | 46 +++++++++++++++++-- src/helm/reflector/templates/cron.yaml | 2 + src/helm/reflector/templates/deployment.yaml | 2 + src/helm/reflector/values.yaml | 4 ++ 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs b/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs index 30fd9e11..901d408f 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 watching. + /// 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/Watchers/Core/WatcherBackgroundService.cs b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs index 1c11fdeb..67afc663 100644 --- a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs +++ b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.RegularExpressions; using System.Threading.Channels; using ES.Kubernetes.Reflector.Configuration; using ES.Kubernetes.Reflector.Watchers.Core.Events; @@ -36,9 +37,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) FullMode = BoundedChannelFullMode.Wait }); + var excludedNamespacePatterns = ParseGlobPatterns(options.CurrentValue.Watcher?.ExcludedNamespaces); + 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 +70,12 @@ await watcherEventHandler.Handle(new WatcherEvent { await foreach (var (type, item) in watchList) { + if (IsNamespaceExcluded(item.Metadata?.NamespaceProperty, excludedNamespacePatterns)) + { + namespaceExcludedCount++; + continue; + } + if (await OnResourceIgnoreCheck(item)) continue; await eventChannel.Writer.WriteAsync(new WatcherEvent { @@ -91,8 +105,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 +126,25 @@ await handler.Handle(new WatcherClosed protected abstract IAsyncEnumerable<(WatchEventType, TResource)> OnGetWatcher(CancellationToken cancellationToken); protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false); + + /// + /// Parses a comma-separated list of glob patterns into compiled Regex objects. + /// Supports * (any characters) and ? (single character). + /// + private static Regex[] ParseGlobPatterns(string? patterns) + { + if (string.IsNullOrWhiteSpace(patterns)) return []; + return patterns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => new Regex( + "^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$", + RegexOptions.Compiled)) + .ToArray(); + } + + private static bool IsNamespaceExcluded(string? ns, Regex[] patterns) + { + if (patterns.Length == 0 || string.IsNullOrEmpty(ns)) return false; + return patterns.Any(p => p.IsMatch(ns)); + } } \ 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..f7626c61 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 watching. + # Supports wildcards: * (any characters), ? (single character). + # Example: "kube-system,*-suffix,prefix-*" + excludedNamespaces: "" kubernetes: skipTlsVerify: false From b906cedc4cf00caafbab18b82f5ad5705d432059 Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Thu, 26 Mar 2026 20:56:25 -0400 Subject: [PATCH 2/6] Add docs about the new Helm config --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ac5a593f..f06ea9cc 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ You can customize the values of the helm deployment by using the following Value | `image.pullPolicy` | Container image pull policy | `IfNotPresent` | | `configuration.logging.minimumLevel` | Logging minimum level | `Information` | | `configuration.watcher.timeout` | Maximum watcher lifetime in seconds | `` | +| `configuration.watcher.excludedNamespaces` | Comma-separated list of namespace glob patterns to exclude from watching. 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` | From 07cb979c41df799e0978457da1c9fba3d445d42d Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Wed, 22 Apr 2026 20:12:17 -0400 Subject: [PATCH 3/6] Add glob matcher tests for excluded namespaces Extract namespace glob matching into a shared internal helper, expose internals to the test assembly, and add unit and integration coverage for skipping reflection from excluded namespaces. --- README.md | 6 +- .../ES.Kubernetes.Reflector.csproj | 8 +- .../Watchers/Core/GlobMatcher.cs | 26 +++ .../Watchers/Core/WatcherBackgroundService.cs | 25 +-- .../ExcludedNamespacesReflectorFixture.cs | 20 ++ .../Fixtures/ReflectorFixture.cs | 5 +- .../Integration/Base/BaseIntegrationTest.cs | 2 +- .../ExcludedNamespacesIntegrationTests.cs | 111 +++++++++++ .../ExcludedNamespacesIntegrationFixture.cs | 20 ++ .../Fixtures/IKubernetesIntegrationFixture.cs | 8 + .../Fixtures/ReflectorIntegrationFixture.cs | 2 +- .../Unit/GlobMatcherTests.cs | 172 ++++++++++++++++++ 12 files changed, 375 insertions(+), 30 deletions(-) create mode 100644 src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs create mode 100644 tests/ES.Kubernetes.Reflector.Tests/Fixtures/ExcludedNamespacesReflectorFixture.cs create mode 100644 tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs create mode 100644 tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ExcludedNamespacesIntegrationFixture.cs create mode 100644 tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/IKubernetesIntegrationFixture.cs create mode 100644 tests/ES.Kubernetes.Reflector.Tests/Unit/GlobMatcherTests.cs diff --git a/README.md b/README.md index f06ea9cc..9e4eb51d 100644 --- a/README.md +++ b/README.md @@ -45,9 +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.watcher.excludedNamespaces` | Comma-separated list of namespace glob patterns to exclude from watching. Supports `*` (any characters) and `?` (single character). Example: `"ephie-*,kube-system,*-temp"` | `` | -| `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/ES.Kubernetes.Reflector.csproj b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj index 5019ad44..34ae8628 100644 --- a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj +++ b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj @@ -8,6 +8,12 @@ false + + + <_Parameter1>ES.Kubernetes.Reflector.Tests + + + @@ -19,4 +25,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..6dd88824 --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs @@ -0,0 +1,26 @@ +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) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .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 cee9c425..ff9d3b4c 100644 --- a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs +++ b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text.RegularExpressions; using System.Threading.Channels; using ES.Kubernetes.Reflector.Configuration; using ES.Kubernetes.Reflector.Watchers.Core.Events; @@ -37,7 +36,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) FullMode = BoundedChannelFullMode.Wait }); - var excludedNamespacePatterns = ParseGlobPatterns(options.CurrentValue.Watcher?.ExcludedNamespaces); + var excludedNamespacePatterns = GlobMatcher.ParseGlobPatterns(options.CurrentValue.Watcher?.ExcludedNamespaces); long namespaceExcludedCount = 0; try { @@ -70,7 +69,7 @@ await watcherEventHandler.Handle(new WatcherEvent { await foreach (var (type, item) in watchList) { - if (IsNamespaceExcluded(item.Metadata?.NamespaceProperty, excludedNamespacePatterns)) + if (GlobMatcher.IsNamespaceExcluded(item.Metadata?.NamespaceProperty, excludedNamespacePatterns)) { namespaceExcludedCount++; continue; @@ -127,24 +126,4 @@ await handler.Handle(new WatcherClosed protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false); - /// - /// Parses a comma-separated list of glob patterns into compiled Regex objects. - /// Supports * (any characters) and ? (single character). - /// - private static Regex[] ParseGlobPatterns(string? patterns) - { - if (string.IsNullOrWhiteSpace(patterns)) return []; - return patterns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => new Regex( - "^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$", - RegexOptions.Compiled)) - .ToArray(); - } - - private static bool IsNamespaceExcluded(string? ns, Regex[] patterns) - { - if (patterns.Length == 0 || string.IsNullOrEmpty(ns)) return false; - return patterns.Any(p => p.IsMatch(ns)); - } } \ No newline at end of file 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 8dbb0b86..3678d785 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..2c9dd178 --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs @@ -0,0 +1,111 @@ +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()}"; + + await CreateNamespaceAsync(excludedNamespace); + await CreateNamespaceAsync(allowedNamespace); + + // 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 mirror normally + var allowedSourceResource = await CreateResource(client, namespaceName: allowedNamespace, + annotations: new ReflectorAnnotationsBuilder() + .WithReflectionAllowed(true) + .WithAllowedNamespaces($"^{allowedNamespace}$") + .WithAutoEnabled(true).Build()); + + await DelayForReflection(); + + // The excluded-namespace resource should not have triggered auto-reflection into the allowed namespace + Assert.False(await ResourceExists(client, + excludedSourceResource.Name(), allowedNamespace, + TestContext.Current.CancellationToken)); + + // The allowed-namespace resource should reflect within its own namespace + Assert.True(await WaitForResource(client, + allowedSourceResource.Name(), allowedNamespace, + 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)); + } +} From 734a6a5d48ee9b02a9a51c90369de2229d7b9a1f Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Wed, 22 Apr 2026 20:29:31 -0400 Subject: [PATCH 4/6] Normalize excluded namespace glob patterns to lowercase --- .../Watchers/Core/GlobMatcher.cs | 7 +++---- .../Watchers/Core/WatcherBackgroundService.cs | 11 +++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs b/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs index 6dd88824..a5ea7d58 100644 --- a/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs +++ b/src/ES.Kubernetes.Reflector/Watchers/Core/GlobMatcher.cs @@ -11,10 +11,9 @@ internal static Regex[] ParseGlobPatterns(string? patterns) return [ .. patterns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => new Regex( - "^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$", - RegexOptions.Compiled)) + .Select(p => new Regex( + "^" + Regex.Escape(p).Replace("\\*", ".*").Replace("\\?", ".") + "$", + RegexOptions.Compiled)) ]; } diff --git a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs index ff9d3b4c..226ed49c 100644 --- a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs +++ b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs @@ -36,8 +36,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) FullMode = BoundedChannelFullMode.Wait }); - var excludedNamespacePatterns = GlobMatcher.ParseGlobPatterns(options.CurrentValue.Watcher?.ExcludedNamespaces); + // 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?.ToLower()); long namespaceExcludedCount = 0; + try { if (excludedNamespacePatterns.Length > 0) @@ -69,6 +73,9 @@ 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++; @@ -126,4 +133,4 @@ await handler.Handle(new WatcherClosed protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false); -} \ No newline at end of file +} From ba7fbe6fd1f665d3be4bb4a959e84d7c57638e91 Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Fri, 24 Apr 2026 18:41:24 -0400 Subject: [PATCH 5/6] Use ToLowerInvariant for excluded namespaces --- .../Watchers/Core/WatcherBackgroundService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs index 226ed49c..83abf4fc 100644 --- a/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs +++ b/src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs @@ -39,7 +39,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // 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?.ToLower()); + var excludedNamespacePatterns = GlobMatcher.ParseGlobPatterns(options.CurrentValue.Watcher?.ExcludedNamespaces?.ToLowerInvariant()); long namespaceExcludedCount = 0; try From dc7fc13df476b440adad23c76ed138cbe37a6c75 Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Fri, 24 Apr 2026 18:42:58 -0400 Subject: [PATCH 6/6] Clarify excluded namespace behavior in tests Update excludedNamespaces docs to describe reflection processing rather than watching, and expand integration coverage to verify excluded source namespaces do not auto-reflect while excluded target namespaces still receive reflections. --- .../Configuration/WatcherOptions.cs | 2 +- src/helm/reflector/values.yaml | 2 +- .../ExcludedNamespacesIntegrationTests.cs | 42 ++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs b/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs index 901d408f..80314e1f 100644 --- a/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs +++ b/src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs @@ -5,7 +5,7 @@ public class WatcherOptions public int? Timeout { get; set; } /// - /// Comma-separated list of namespace patterns to exclude from watching. + /// Comma-separated list of namespace patterns to exclude from reflection processing. /// Supports glob wildcards: * (any characters), ? (single character). /// Example: "ephie-*,kube-system,*-temp" /// diff --git a/src/helm/reflector/values.yaml b/src/helm/reflector/values.yaml index f7626c61..bb5cbc92 100644 --- a/src/helm/reflector/values.yaml +++ b/src/helm/reflector/values.yaml @@ -28,7 +28,7 @@ configuration: minimumLevel: Information watcher: timeout: "" - # Comma-separated list of namespace glob patterns to exclude from watching. + # Comma-separated list of namespace glob patterns to exclude from reflection processing. # Supports wildcards: * (any characters), ? (single character). # Example: "kube-system,*-suffix,prefix-*" excludedNamespaces: "" diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs index 2c9dd178..6cb3167c 100644 --- a/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs +++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/ExcludedNamespacesIntegrationTests.cs @@ -24,9 +24,11 @@ public async Task WatchEvents_FromExcludedNamespace_AreNotReflected() 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, @@ -34,23 +36,51 @@ public async Task WatchEvents_FromExcludedNamespace_AreNotReflected() .WithReflectionAllowed(true) .WithAutoEnabled(true).Build()); - // Resource in allowed namespace — should mirror normally + // Resource in allowed namespace — should cross-namespace auto-reflect into targetNamespace var allowedSourceResource = await CreateResource(client, namespaceName: allowedNamespace, annotations: new ReflectorAnnotationsBuilder() .WithReflectionAllowed(true) - .WithAllowedNamespaces($"^{allowedNamespace}$") + .WithAllowedNamespaces("^target-.*") .WithAutoEnabled(true).Build()); await DelayForReflection(); - // The excluded-namespace resource should not have triggered auto-reflection into the allowed namespace + // The excluded-namespace resource should not have triggered auto-reflection into any other namespace Assert.False(await ResourceExists(client, - excludedSourceResource.Name(), allowedNamespace, + excludedSourceResource.Name(), targetNamespace, TestContext.Current.CancellationToken)); - // The allowed-namespace resource should reflect within its own namespace + // The allowed-namespace resource should cross-namespace reflect into targetNamespace Assert.True(await WaitForResource(client, - allowedSourceResource.Name(), allowedNamespace, + 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)); }