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)); + } +}