diff --git a/README.md b/README.md index ac5a593f..b941e4fc 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,13 @@ $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-refle - Add `reflector.v1.k8s.emberstack.com/reflection-allowed: "true"` to the resource annotations to permit reflection to mirrors. - Add `reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: ""` to the resource annotations to permit reflection from only the list of comma separated namespaces or regular expressions. Note: If this annotation is omitted or is empty, all namespaces are allowed. + - Add `reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces-selector: ""` to the resource annotations to permit reflection only to namespaces matching the given Kubernetes label selector (e.g. `env=production`, `team in (a,b)`). If both this and `reflection-allowed-namespaces` are set, a namespace matches if it satisfies either condition. #### Automatic mirror creation: Reflector can create mirrors with the same name in other namespaces automatically. The following annotations control if and how the mirrors are created: - Add `reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"` to the resource annotations to automatically create mirrors in other namespaces. Note: Requires `reflector.v1.k8s.emberstack.com/reflection-allowed` to be `true` since mirrors need to able to reflect the source. - Add `reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: ""` to the resource annotations specify in which namespaces to automatically create mirrors. Note: If this annotation is omitted or is empty, all namespaces are allowed. Namespaces in this list will also be checked by `reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces` since mirrors need to be in namespaces from where reflection is permitted. + - Add `reflector.v1.k8s.emberstack.com/reflection-auto-namespaces-selector: ""` to the resource annotations to select namespaces for automatic mirrors using a Kubernetes label selector. If both this and `reflection-auto-namespaces` are set, a namespace matches if it satisfies either condition. > Important: If the `source` is deleted, automatic mirrors are deleted. Also if either reflection or automirroring is turned off or the automatic mirror's namespace is no longer a valid match for the allowed namespaces, the automatic mirror is deleted. @@ -98,10 +100,11 @@ $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-refle annotations: reflector.v1.k8s.emberstack.com/reflection-allowed: "true" reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" + reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces-selector: "env=production" data: ... ``` - + Example source configmap: ```yaml apiVersion: v1 @@ -111,10 +114,11 @@ $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-refle annotations: reflector.v1.k8s.emberstack.com/reflection-allowed: "true" reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" + reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces-selector: "env=production" data: ... ``` - + ### 2. Annotate the mirror secret or configmap - Add `reflector.v1.k8s.emberstack.com/reflects: "/"` to the mirror object. The value of the annotation is the full name of the source object in `namespace/name` format. diff --git a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj index 5019ad44..c0b0f6ee 100644 --- a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj +++ b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj @@ -8,6 +8,10 @@ false + + + + diff --git a/src/ES.Kubernetes.Reflector/Mirroring/Core/Annotations.cs b/src/ES.Kubernetes.Reflector/Mirroring/Core/Annotations.cs index 41d22c56..09fceda3 100644 --- a/src/ES.Kubernetes.Reflector/Mirroring/Core/Annotations.cs +++ b/src/ES.Kubernetes.Reflector/Mirroring/Core/Annotations.cs @@ -8,8 +8,10 @@ public static class Reflection { public static string Allowed => $"{Prefix}/reflection-allowed"; public static string AllowedNamespaces => $"{Prefix}/reflection-allowed-namespaces"; + public static string AllowedNamespacesSelector => $"{Prefix}/reflection-allowed-namespaces-selector"; public static string AutoEnabled => $"{Prefix}/reflection-auto-enabled"; public static string AutoNamespaces => $"{Prefix}/reflection-auto-namespaces"; + public static string AutoNamespacesSelector => $"{Prefix}/reflection-auto-namespaces-selector"; public static string Reflects => $"{Prefix}/reflects"; diff --git a/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringProperties.cs b/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringProperties.cs index ac08a41f..1457d8e1 100644 --- a/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringProperties.cs +++ b/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringProperties.cs @@ -7,8 +7,10 @@ public class MirroringProperties { public bool Allowed { get; set; } public string AllowedNamespaces { get; set; } = string.Empty; + public string AllowedNamespacesSelector { get; set; } = string.Empty; public bool AutoEnabled { get; set; } public string AutoNamespaces { get; set; } = string.Empty; + public string AutoNamespacesSelector { get; set; } = string.Empty; public NamespacedName? Reflects { get; set; } public string ResourceVersion { get; set; } = string.Empty; diff --git a/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringPropertiesExtensions.cs b/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringPropertiesExtensions.cs index 61f78afb..404ecdae 100644 --- a/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringPropertiesExtensions.cs +++ b/src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringPropertiesExtensions.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using ES.FX.Additions.KubernetesClient.Models; using ES.FX.Additions.KubernetesClient.Models.Extensions; using k8s; @@ -8,6 +8,26 @@ namespace ES.Kubernetes.Reflector.Mirroring.Core; public static class MirroringPropertiesExtensions { + // Kubernetes label name: 1-63 chars, alphanumeric plus _ . -, must start/end alphanumeric. + private static readonly Regex LabelNameRegex = new( + @"^[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$", + RegexOptions.Compiled); + + // Kubernetes label key prefix: DNS subdomain (lowercase alphanumeric and -, period-separated labels). + private static readonly Regex LabelPrefixRegex = new( + @"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + RegexOptions.Compiled); + + // Kubernetes label value: up to 63 chars, same character rules as the name (empty values are allowed). + private static readonly Regex LabelValueRegex = new( + @"^([A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?)?$", + RegexOptions.Compiled); + + // Matches " in ()" or " notin ()" at the requirement level. + private static readonly Regex SetBasedRequirementRegex = new( + @"^(?\S+)\s+(?in|notin)\s*\((?[^)]*)\)$", + RegexOptions.Compiled); + public static MirroringProperties GetMirroringProperties(this IKubernetesObject resource) => resource.EnsureMetadata().GetMirroringProperties(); @@ -24,6 +44,12 @@ public static MirroringProperties GetMirroringProperties(this V1ObjectMeta metad ? allowedNamespaces ?? string.Empty : string.Empty, + AllowedNamespacesSelector = metadata + .TryGetAnnotationValue(Annotations.Reflection.AllowedNamespacesSelector, + out string? allowedNamespacesSelector) + ? allowedNamespacesSelector ?? string.Empty + : string.Empty, + AutoEnabled = metadata .TryGetAnnotationValue(Annotations.Reflection.AutoEnabled, out bool autoEnabled) && autoEnabled, @@ -32,6 +58,12 @@ public static MirroringProperties GetMirroringProperties(this V1ObjectMeta metad ? autoNamespaces ?? string.Empty : string.Empty, + AutoNamespacesSelector = metadata + .TryGetAnnotationValue(Annotations.Reflection.AutoNamespacesSelector, + out string? autoNamespacesSelector) + ? autoNamespacesSelector ?? string.Empty + : string.Empty, + Reflects = metadata .TryGetAnnotationValue(Annotations.Reflection.Reflects, out string? metaReflects) ? NamespacedName.TryParse(metaReflects, out var id) ? id : null @@ -55,14 +87,71 @@ public static MirroringProperties GetMirroringProperties(this V1ObjectMeta metad : null }; + /// + /// Checks if the source properties allow reflection to the given namespace (by name only). + /// Use the overload accepting V1Namespace for label selector support. + /// public static bool CanBeReflectedToNamespace(this MirroringProperties properties, string ns) => properties.Allowed && PatternListMatch(properties.AllowedNamespaces, ns); + /// + /// Checks if the source properties allow reflection to the given namespace, + /// including label selector matching when a V1Namespace object is available. + /// + public static bool CanBeReflectedToNamespace(this MirroringProperties properties, V1Namespace ns) => + properties.Allowed && MatchNamespace(properties.AllowedNamespaces, properties.AllowedNamespacesSelector, ns); + /// + /// Checks if the source properties allow auto-reflection to the given namespace (by name only). + /// Use the overload accepting V1Namespace for label selector support. + /// public static bool CanBeAutoReflectedToNamespace(this MirroringProperties properties, string ns) => properties.CanBeReflectedToNamespace(ns) && properties.AutoEnabled && PatternListMatch(properties.AutoNamespaces, ns); + /// + /// Checks if the source properties allow auto-reflection to the given namespace, + /// including label selector matching when a V1Namespace object is available. + /// + public static bool CanBeAutoReflectedToNamespace(this MirroringProperties properties, V1Namespace ns) => + properties.CanBeReflectedToNamespace(ns) && properties.AutoEnabled && + MatchNamespace(properties.AutoNamespaces, properties.AutoNamespacesSelector, ns); + + /// + /// Parses the label selector annotations on this properties instance and returns a list of parse errors, + /// one per malformed selector. Returns an empty list if all selectors are valid (or unset). Callers can + /// surface these errors so operators get feedback about misconfigured annotations. + /// + public static IReadOnlyList GetLabelSelectorErrors(this MirroringProperties properties) + { + var errors = new List(); + CollectLabelSelectorErrors( + Annotations.Reflection.AllowedNamespacesSelector, + properties.AllowedNamespacesSelector, errors); + CollectLabelSelectorErrors( + Annotations.Reflection.AutoNamespacesSelector, + properties.AutoNamespacesSelector, errors); + return errors; + } + + /// + /// Returns true if the namespace matches either the name pattern list or the label selector (OR logic). + /// If both are empty, returns true (allow all). + /// + private static bool MatchNamespace(string patternList, string labelSelector, V1Namespace ns) + { + var hasPatterns = !string.IsNullOrEmpty(patternList); + var hasSelector = !string.IsNullOrEmpty(labelSelector); + + // If neither is set, allow all (same as existing behavior) + if (!hasPatterns && !hasSelector) return true; + + // OR logic: match if either the name pattern or label selector matches + if (hasPatterns && PatternListMatch(patternList, ns.Name())) return true; + if (hasSelector && LabelSelectorMatch(labelSelector, ns)) return true; + + return false; + } private static bool PatternListMatch(string patternList, string value) { @@ -72,4 +161,303 @@ private static bool PatternListMatch(string patternList, string value) .Select(pattern => Regex.Match(value, pattern)) .Any(match => match.Success && match.Value.Length == value.Length); } -} \ No newline at end of file + + /// + /// Matches a Kubernetes label selector string against namespace labels. + /// Supports equality-based (=, ==, !=), set-based (in, notin), and existence-based (key, !key) selectors. + /// Multiple selectors separated by commas are ANDed together. Invalid selectors fail closed (return false). + /// + internal static bool LabelSelectorMatch(string selector, V1Namespace ns) + { + if (string.IsNullOrWhiteSpace(selector)) return true; + + if (!TryParseLabelSelector(selector, out var parsed, out _)) + return false; + + var labels = ns.Metadata?.Labels ?? new Dictionary(); + return MatchesLabels(parsed, labels); + } + + /// + /// Parses a Kubernetes label selector string into a . Returns false if the + /// selector is malformed; lists the reasons. An empty or whitespace selector + /// parses successfully into an empty selector (matches everything). + /// + internal static bool TryParseLabelSelector(string raw, out V1LabelSelector selector, + out IReadOnlyList errors) + { + selector = new V1LabelSelector(); + var errorList = new List(); + errors = errorList; + + if (string.IsNullOrWhiteSpace(raw)) return true; + + var requirements = SplitRequirements(raw); + if (requirements.Count == 0) + { + errorList.Add("selector is not empty but contains no requirements"); + return false; + } + + var matchLabels = new Dictionary(); + var matchExpressions = new List(); + + foreach (var requirement in requirements) + { + if (TryParseSetBased(requirement, out var setRequirement, out var setError)) + { + if (setError != null) errorList.Add(setError); + else matchExpressions.Add(setRequirement!); + continue; + } + + if (TryParseInequality(requirement, errorList, out var neqRequirement)) + { + if (neqRequirement != null) matchExpressions.Add(neqRequirement); + continue; + } + + if (TryParseEquality(requirement, errorList, matchLabels)) continue; + + if (TryParseExistence(requirement, errorList, out var existRequirement)) + { + if (existRequirement != null) matchExpressions.Add(existRequirement); + continue; + } + + errorList.Add($"requirement '{requirement}' could not be parsed"); + } + + if (errorList.Count > 0) return false; + + if (matchLabels.Count > 0) selector.MatchLabels = matchLabels; + if (matchExpressions.Count > 0) selector.MatchExpressions = matchExpressions; + return true; + } + + /// + /// Evaluates a against a label dictionary using the standard Kubernetes + /// semantics (MatchLabels are ANDed; MatchExpressions are ANDed). + /// + internal static bool MatchesLabels(V1LabelSelector selector, IDictionary labels) + { + if (selector.MatchLabels != null) + foreach (var kv in selector.MatchLabels) + if (!labels.TryGetValue(kv.Key, out var labelValue) || labelValue != kv.Value) + return false; + + if (selector.MatchExpressions == null) return true; + + foreach (var expression in selector.MatchExpressions) + { + var hasLabel = labels.TryGetValue(expression.Key, out var labelValue); + switch (expression.OperatorProperty) + { + case "In": + if (!hasLabel || expression.Values == null || + !expression.Values.Contains(labelValue!)) return false; + break; + case "NotIn": + if (hasLabel && expression.Values != null && + expression.Values.Contains(labelValue!)) return false; + break; + case "Exists": + if (!hasLabel) return false; + break; + case "DoesNotExist": + if (hasLabel) return false; + break; + default: + return false; + } + } + + return true; + } + + private static void CollectLabelSelectorErrors(string annotation, string value, List destination) + { + if (string.IsNullOrWhiteSpace(value)) return; + if (TryParseLabelSelector(value, out _, out var parseErrors)) return; + foreach (var error in parseErrors) + destination.Add($"{annotation} '{value}': {error}"); + } + + private static List SplitRequirements(string selector) + { + var results = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < selector.Length; i++) + switch (selector[i]) + { + case '(': + depth++; + break; + case ')': + depth--; + break; + case ',' when depth == 0: + var part = selector[start..i].Trim(); + if (part.Length > 0) results.Add(part); + start = i + 1; + break; + } + + var last = selector[start..].Trim(); + if (last.Length > 0) results.Add(last); + return results; + } + + private static bool TryParseSetBased(string requirement, out V1LabelSelectorRequirement? result, + out string? error) + { + result = null; + error = null; + + var match = SetBasedRequirementRegex.Match(requirement); + if (!match.Success) return false; + + var key = match.Groups["key"].Value; + var op = match.Groups["op"].Value; + var values = match.Groups["values"].Value + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => v.Trim()) + .ToList(); + + if (!IsValidLabelKey(key)) + { + error = $"invalid label key '{key}' in set-based requirement"; + return true; + } + + if (values.Count == 0) + { + error = $"set-based requirement for key '{key}' has no values"; + return true; + } + + foreach (var value in values) + if (!IsValidLabelValue(value)) + { + error = $"invalid label value '{value}' for key '{key}'"; + return true; + } + + result = new V1LabelSelectorRequirement + { + Key = key, + OperatorProperty = op == "in" ? "In" : "NotIn", + Values = values + }; + return true; + } + + private static bool TryParseInequality(string requirement, List errors, + out V1LabelSelectorRequirement? result) + { + result = null; + if (!requirement.Contains("!=")) return false; + + var parts = requirement.Split("!=", 2); + var key = parts[0].Trim(); + var value = parts[1].Trim(); + + if (!IsValidLabelKey(key)) + { + errors.Add($"invalid label key '{key}' in inequality requirement"); + return true; + } + + if (!IsValidLabelValue(value)) + { + errors.Add($"invalid label value '{value}' for key '{key}'"); + return true; + } + + result = new V1LabelSelectorRequirement + { + Key = key, + OperatorProperty = "NotIn", + Values = new List { value } + }; + return true; + } + + private static bool TryParseEquality(string requirement, List errors, + Dictionary matchLabels) + { + var doubleEq = requirement.IndexOf("==", StringComparison.Ordinal); + var singleEq = requirement.IndexOf('='); + if (doubleEq < 0 && singleEq < 0) return false; + + var eqIndex = doubleEq >= 0 ? doubleEq : singleEq; + var opLength = doubleEq >= 0 ? 2 : 1; + var key = requirement[..eqIndex].Trim(); + var value = requirement[(eqIndex + opLength)..].Trim(); + + if (!IsValidLabelKey(key)) + { + errors.Add($"invalid label key '{key}' in equality requirement"); + return true; + } + + if (!IsValidLabelValue(value)) + { + errors.Add($"invalid label value '{value}' for key '{key}'"); + return true; + } + + matchLabels[key] = value; + return true; + } + + private static bool TryParseExistence(string requirement, List errors, + out V1LabelSelectorRequirement? result) + { + result = null; + var negated = requirement.StartsWith('!'); + var key = negated ? requirement[1..].Trim() : requirement; + + if (!IsValidLabelKey(key)) + { + errors.Add($"invalid label key '{key}' in existence requirement"); + return true; + } + + result = new V1LabelSelectorRequirement + { + Key = key, + OperatorProperty = negated ? "DoesNotExist" : "Exists" + }; + return true; + } + + private static bool IsValidLabelKey(string key) + { + if (string.IsNullOrEmpty(key)) return false; + + var slashIndex = key.IndexOf('/'); + string name; + if (slashIndex >= 0) + { + var prefix = key[..slashIndex]; + if (prefix.Length == 0 || prefix.Length > 253) return false; + if (!LabelPrefixRegex.IsMatch(prefix)) return false; + name = key[(slashIndex + 1)..]; + } + else + { + name = key; + } + + if (name.Length == 0 || name.Length > 63) return false; + return LabelNameRegex.IsMatch(name); + } + + private static bool IsValidLabelValue(string value) + { + if (value.Length > 63) return false; + return LabelValueRegex.IsMatch(value); + } +} diff --git a/src/ES.Kubernetes.Reflector/Mirroring/Core/ResourceMirror.cs b/src/ES.Kubernetes.Reflector/Mirroring/Core/ResourceMirror.cs index 69dff9e5..e6ca5c79 100644 --- a/src/ES.Kubernetes.Reflector/Mirroring/Core/ResourceMirror.cs +++ b/src/ES.Kubernetes.Reflector/Mirroring/Core/ResourceMirror.cs @@ -16,12 +16,16 @@ public abstract class ResourceMirror(ILogger logger, IKubernetes kube IWatcherEventHandler, IWatcherClosedHandler where TResource : class, IKubernetesObject { + private static readonly IDictionary EmptyLabels = new Dictionary(); + private readonly ConcurrentDictionary> _autoReflectionCache = new(); private readonly ConcurrentDictionary _autoSources = new(); private readonly ConcurrentDictionary> _directReflectionCache = new(); + private readonly ConcurrentDictionary _namespaceCache = new(); private readonly ConcurrentDictionary _notFoundCache = new(); private readonly ConcurrentDictionary _propertiesCache = new(); + private readonly ConcurrentDictionary _lastWarnedSelectorErrors = new(); protected readonly IKubernetes Kubernetes = kubernetes; protected readonly ILogger Logger = logger; @@ -38,9 +42,11 @@ public Task Handle(WatcherClosed notification, CancellationToken cancellationTok Logger.LogDebug("Cleared sources for {Type} resources", typeof(TResource).Name); _autoSources.Clear(); + _namespaceCache.Clear(); _notFoundCache.Clear(); _propertiesCache.Clear(); _autoReflectionCache.Clear(); + _lastWarnedSelectorErrors.Clear(); return Task.CompletedTask; } @@ -72,6 +78,7 @@ public async Task Handle(WatcherEvent notification, CancellationToken cancellati case WatchEventType.Deleted: { _propertiesCache.Remove(objNsName, out _); + _lastWarnedSelectorErrors.TryRemove(objNsName, out _); var properties = obj.GetMirroringProperties(); if (!properties.IsReflection) @@ -103,35 +110,90 @@ public async Task Handle(WatcherEvent notification, CancellationToken cancellati } break; - case V1Namespace ns when notification.EventType == WatchEventType.Added: + case V1Namespace ns when notification.EventType is WatchEventType.Added or WatchEventType.Modified: { Logger.LogTrace("Handling {eventType} {resourceType} {resourceRef}", notification.EventType, ns.Kind, ns.ObjectReference().NamespacedName()); + // Skip reconciliation when only non-label fields changed (status, annotations, resourceVersion). + // Reflection eligibility is purely a function of namespace name and labels. + if (notification.EventType == WatchEventType.Modified && + _namespaceCache.TryGetValue(ns.Name(), out var cachedNs) && + NamespaceLabelsEqual(cachedNs, ns)) + break; + + //Cache the namespace for label selector lookups + _namespaceCache.AddOrUpdate(ns.Name(), ns, (_, _) => ns); + //Update all auto-sources foreach (var sourceNsName in _autoSources.Keys) { var properties = _propertiesCache[sourceNsName]; + var autoReflections = _autoReflectionCache.GetOrAdd(sourceNsName, []); + var reflectionNsName = sourceNsName with { Namespace = ns.Name() }; - //If it can't be reflected to this namespace, skip - if (!properties.CanBeAutoReflectedToNamespace(ns.Name())) continue; + if (properties.CanBeAutoReflectedToNamespace(ns)) + { + //Create or update the auto-reflection in this namespace + await ResourceReflect( + sourceNsName, + reflectionNsName, + null, + null, + true); + autoReflections.Add(reflectionNsName); + } + else if (autoReflections.Remove(reflectionNsName)) + { + //Namespace no longer matches — remove the auto-reflection + Logger.LogDebug( + "Deleting {reflectionNsName} - namespace {ns} no longer matches selector for source {sourceNsName}", + reflectionNsName, ns.Name(), sourceNsName); + await OnResourceDelete(reflectionNsName); + } + } - //Get the list of auto-reflections - var autoReflections = _autoReflectionCache.GetOrAdd(sourceNsName, []); + //Rebalance any direct reflections targeting this namespace against current labels + foreach (var (sourceNsName, reflectionList) in _directReflectionCache) + { + if (!_propertiesCache.TryGetValue(sourceNsName, out var sourceProperties)) continue; - var reflectionNsName = sourceNsName with { Namespace = ns.Name() }; + var staleReflections = reflectionList + .Where(r => r.Namespace == ns.Name()) + .ToList(); + if (staleReflections.Count == 0) continue; + + if (CanBeReflectedToNamespaceCached(sourceProperties, ns.Name())) continue; + + foreach (var reflectionNsName in staleReflections) + { + Logger.LogInformation( + "Source {sourceNsName} no longer permits the direct reflection to {reflectionNsName}.", + sourceNsName, reflectionNsName); + reflectionList.Remove(reflectionNsName); + } + } + } + break; + case V1Namespace ns when notification.EventType == WatchEventType.Deleted: + { + Logger.LogTrace("Handling {eventType} {resourceType} {resourceRef}", notification.EventType, ns.Kind, + ns.ObjectReference().NamespacedName()); - //Reflect the auto-source to the new namespace - await ResourceReflect( - sourceNsName, - reflectionNsName, - null, - null, - true); + _namespaceCache.TryRemove(ns.Name(), out _); - autoReflections.Add(reflectionNsName); + //Remove any auto-reflections targeting this namespace + foreach (var sourceNsName in _autoSources.Keys) + { + var autoReflections = _autoReflectionCache.GetOrAdd(sourceNsName, []); + var reflectionNsName = sourceNsName with { Namespace = ns.Name() }; + autoReflections.Remove(reflectionNsName); } + + //Remove any direct reflections targeting this namespace + foreach (var reflectionList in _directReflectionCache.Values) + reflectionList.RemoveWhere(r => r.Namespace == ns.Name()); } break; } @@ -146,6 +208,8 @@ private async Task HandleUpsert(TResource obj, CancellationToken cancellationTok _propertiesCache.AddOrUpdate(objNsName, objProperties, (_, _) => objProperties); + WarnOnInvalidLabelSelectors(objNsName, objProperties); + switch (objProperties) { //If the resource is not a reflection @@ -155,7 +219,7 @@ private async Task HandleUpsert(TResource obj, CancellationToken cancellationTok if (_directReflectionCache.TryGetValue(objNsName, out var reflectionList)) { var reflections = reflectionList - .Where(s => !objProperties.CanBeReflectedToNamespace(s.Namespace)) + .Where(s => !CanBeReflectedToNamespaceCached(objProperties, s.Namespace)) .ToHashSet(); foreach (var reflectionNsName in reflections) @@ -172,7 +236,7 @@ private async Task HandleUpsert(TResource obj, CancellationToken cancellationTok if (_autoReflectionCache.TryGetValue(objNsName, out reflectionList)) { var reflections = reflectionList - .Where(s => !objProperties.CanBeReflectedToNamespace(s.Namespace)) + .Where(s => !CanBeReflectedToNamespaceCached(objProperties, s.Namespace)) .ToHashSet(); foreach (var reflectionNsName in reflections) { @@ -263,7 +327,7 @@ await ResourceReflect(objNsName, _directReflectionCache.TryAdd(sourceNsName, []); _directReflectionCache[sourceNsName].Add(objNsName); - if (!sourceProperties.CanBeReflectedToNamespace(objNsName.Namespace)) + if (!CanBeReflectedToNamespaceCached(sourceProperties, objNsName.Namespace)) { Logger.LogWarning("Could not update {reflectionNsName} - Source {sourceNsName} does not permit it.", objNsName, sourceNsName); @@ -326,7 +390,7 @@ await ResourceReflect( _propertiesCache.AddOrUpdate(sourceNsName, sourceProperties, (_, _) => sourceProperties); - if (!sourceProperties.CanBeAutoReflectedToNamespace(objNsName.Namespace)) + if (!CanBeAutoReflectedToNamespaceCached(sourceProperties, objNsName.Namespace)) { Logger.LogInformation( "Source {sourceNsName} no longer permits the auto reflection to {reflectionNsName}. Deleting {reflectionNsName}.", @@ -354,6 +418,10 @@ private async Task AutoReflectionForSource(NamespacedName sourceNsName, TResourc var namespaces = (await Kubernetes.CoreV1 .ListNamespaceAsync(cancellationToken: cancellationToken)).Items; + //Cache namespaces for label selector lookups + foreach (var ns in namespaces) + _namespaceCache.AddOrUpdate(ns.Name(), ns, (_, _) => ns); + foreach (var match in matches) { var matchProperties = match.GetMirroringProperties(); @@ -361,9 +429,12 @@ private async Task AutoReflectionForSource(NamespacedName sourceNsName, TResourc _ => matchProperties, (_, _) => matchProperties); } + var namespaceLookup = namespaces.ToDictionary(n => n.Name()); + var toDelete = matches .Where(s => s.Namespace() != sourceNsName.Namespace) - .Where(m => !sourceProperties.CanBeAutoReflectedToNamespace(m.Namespace())) + .Where(m => !namespaceLookup.TryGetValue(m.Namespace(), out var ns) || + !sourceProperties.CanBeAutoReflectedToNamespace(ns)) .Where(m => m.GetMirroringProperties().Reflects == sourceNsName) .Select(s => s.NamespacedName()) .ToList(); @@ -377,7 +448,7 @@ private async Task AutoReflectionForSource(NamespacedName sourceNsName, TResourc .Where(s => s.Name() != sourceNsName.Namespace) .Where(s => matches.All(m => m.Namespace() != s.Name()) && - sourceProperties.CanBeAutoReflectedToNamespace(s.Name())) + sourceProperties.CanBeAutoReflectedToNamespace(s)) .Select(s => new NamespacedName(s.Name(), sourceNsName.Name)).ToList(); var toUpdate = matches @@ -557,4 +628,59 @@ private async Task ResourceReflect(NamespacedName sourceNsName, NamespacedName r protected abstract Task OnResourceGet(NamespacedName refId); protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false); + + private void WarnOnInvalidLabelSelectors(NamespacedName sourceNsName, MirroringProperties properties) + { + var errors = properties.GetLabelSelectorErrors(); + if (errors.Count == 0) + { + _lastWarnedSelectorErrors.TryRemove(sourceNsName, out _); + return; + } + + var signature = string.Join("|", errors); + if (_lastWarnedSelectorErrors.TryGetValue(sourceNsName, out var previous) && previous == signature) return; + + _lastWarnedSelectorErrors[sourceNsName] = signature; + foreach (var error in errors) + Logger.LogWarning("Invalid label selector on source {sourceNsName}: {error}", sourceNsName, error); + } + + internal static bool NamespaceLabelsEqual(V1Namespace a, V1Namespace b) => + (a.Metadata?.Labels ?? EmptyLabels).SequenceEqual(b.Metadata?.Labels ?? EmptyLabels); + + private bool CanBeReflectedToNamespaceCached(MirroringProperties properties, string ns) + { + if (_namespaceCache.TryGetValue(ns, out var nsObj)) + return properties.CanBeReflectedToNamespace(nsObj); + + // Fail closed: a label selector cannot be evaluated without the namespace object. + // Falling back to the name-only overload would match empty-pattern sources and allow all namespaces. + if (!string.IsNullOrEmpty(properties.AllowedNamespacesSelector)) + { + Logger.LogDebug( + "Namespace {ns} not in cache; denying reflection because a label selector is configured.", ns); + return false; + } + + return properties.CanBeReflectedToNamespace(ns); + } + + private bool CanBeAutoReflectedToNamespaceCached(MirroringProperties properties, string ns) + { + if (_namespaceCache.TryGetValue(ns, out var nsObj)) + return properties.CanBeAutoReflectedToNamespace(nsObj); + + // Fail closed: a label selector cannot be evaluated without the namespace object. + // Falling back to the name-only overload would match empty-pattern sources and allow all namespaces. + if (!string.IsNullOrEmpty(properties.AllowedNamespacesSelector) || + !string.IsNullOrEmpty(properties.AutoNamespacesSelector)) + { + Logger.LogDebug( + "Namespace {ns} not in cache; denying auto-reflection because a label selector is configured.", ns); + return false; + } + + return properties.CanBeAutoReflectedToNamespace(ns); + } } \ No newline at end of file diff --git a/tests/ES.Kubernetes.Reflector.Tests/Additions/ReflectorAnnotationsBuilder.cs b/tests/ES.Kubernetes.Reflector.Tests/Additions/ReflectorAnnotationsBuilder.cs index 3d69ea48..f562a3c1 100644 --- a/tests/ES.Kubernetes.Reflector.Tests/Additions/ReflectorAnnotationsBuilder.cs +++ b/tests/ES.Kubernetes.Reflector.Tests/Additions/ReflectorAnnotationsBuilder.cs @@ -18,6 +18,12 @@ public ReflectorAnnotationsBuilder WithAllowedNamespaces(params string[] namespa return this; } + public ReflectorAnnotationsBuilder WithAllowedNamespacesSelector(string selector) + { + _annotations[Annotations.Reflection.AllowedNamespacesSelector] = selector; + return this; + } + public ReflectorAnnotationsBuilder WithAutoEnabled(bool enabled) { _annotations[Annotations.Reflection.AutoEnabled] = enabled.ToString().ToLower(); @@ -30,6 +36,12 @@ public ReflectorAnnotationsBuilder WithAutoNamespaces(bool enabled, params strin return this; } + public ReflectorAnnotationsBuilder WithAutoNamespacesSelector(string selector) + { + _annotations[Annotations.Reflection.AutoNamespacesSelector] = selector; + return this; + } + public Dictionary Build() { if (_annotations.Count != 0) return _annotations; diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs index 8dbb0b86..28c14cc1 100644 --- a/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs +++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs @@ -24,10 +24,22 @@ public class BaseIntegrationTest(ReflectorIntegrationFixture integrationFixture) .AddTimeout(TimeSpan.FromSeconds(30)) .Build(); + protected static readonly ResiliencePipeline ResourceAbsentResiliencePipeline = + new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder().HandleResult(true), + MaxRetryAttempts = 10, + Delay = TimeSpan.FromSeconds(1) + }) + .AddTimeout(TimeSpan.FromSeconds(30)) + .Build(); + protected async Task GetKubernetesClient() => await integrationFixture.Kubernetes.GetKubernetesClient(); - protected async Task CreateNamespaceAsync(string name) + protected async Task CreateNamespaceAsync(string name, + IDictionary? labels = null) { var client = await GetKubernetesClient(); var ns = new V1Namespace @@ -36,7 +48,8 @@ protected async Task GetKubernetesClient() => Kind = V1Namespace.KubeKind, Metadata = new V1ObjectMeta { - Name = name + Name = name, + Labels = labels } }; diff --git a/tests/ES.Kubernetes.Reflector.Tests/Integration/MirroringIntegrationTests.cs b/tests/ES.Kubernetes.Reflector.Tests/Integration/MirroringIntegrationTests.cs index 13fb1480..3bdf314a 100644 --- a/tests/ES.Kubernetes.Reflector.Tests/Integration/MirroringIntegrationTests.cs +++ b/tests/ES.Kubernetes.Reflector.Tests/Integration/MirroringIntegrationTests.cs @@ -81,6 +81,133 @@ public async Task AutoReflect_To_NewNamespaces() } + [Fact] + public async Task AutoReflect_To_NamespacesMatchingLabelSelector() + { + var client = await GetKubernetesClient(); + + var matchingNamespace = $"match-{Guid.CreateVersion7()}"; + var nonMatchingNamespace = $"nomatch-{Guid.CreateVersion7()}"; + + await CreateNamespaceAsync(matchingNamespace, + new Dictionary { ["reflector-test-env"] = "prod" }); + await CreateNamespaceAsync(nonMatchingNamespace, + new Dictionary { ["reflector-test-env"] = "dev" }); + + var sourceResource = await CreateResource(client, + annotations: new ReflectorAnnotationsBuilder() + .WithReflectionAllowed(true) + .WithAllowedNamespacesSelector("reflector-test-env=prod") + .WithAutoEnabled(true).Build()); + + await DelayForReflection(); + + Assert.True(await WaitForResource(client, sourceResource.Name(), matchingNamespace, + TestContext.Current.CancellationToken)); + + Assert.False(await ResourceExists(client, sourceResource.Name(), nonMatchingNamespace, + TestContext.Current.CancellationToken)); + } + + + [Fact] + public async Task AutoReflect_UpdatesReflections_WhenNamespaceLabelsChange() + { + var client = await GetKubernetesClient(); + + var targetNamespace = $"labelshift-{Guid.CreateVersion7()}"; + await CreateNamespaceAsync(targetNamespace, + new Dictionary { ["reflector-test-env"] = "dev" }); + + var sourceResource = await CreateResource(client, + annotations: new ReflectorAnnotationsBuilder() + .WithReflectionAllowed(true) + .WithAllowedNamespacesSelector("reflector-test-env=prod") + .WithAutoEnabled(true).Build()); + + await DelayForReflection(); + + Assert.False(await ResourceExists(client, sourceResource.Name(), targetNamespace, + TestContext.Current.CancellationToken)); + + await PatchNamespaceLabelsAsync(client, targetNamespace, + new Dictionary { ["reflector-test-env"] = "prod" }); + + Assert.True(await WaitForResource(client, sourceResource.Name(), targetNamespace, + TestContext.Current.CancellationToken)); + + await PatchNamespaceLabelsAsync(client, targetNamespace, + new Dictionary { ["reflector-test-env"] = "dev" }); + + Assert.True(await WaitForResourceAbsent(client, sourceResource.Name(), targetNamespace, + TestContext.Current.CancellationToken)); + } + + + [Fact] + public async Task DirectReflect_StopsUpdating_WhenNamespaceLabelsNoLongerMatchSelector() + { + var client = await GetKubernetesClient(); + + var sourceNamespace = $"src-{Guid.CreateVersion7()}"; + var targetNamespace = $"labelshift-direct-{Guid.CreateVersion7()}"; + var dataKey = "payload"; + var initialValue = Guid.NewGuid().ToString(); + var updatedValue = Guid.NewGuid().ToString(); + + await CreateNamespaceAsync(targetNamespace, + new Dictionary { ["reflector-test-env"] = "prod" }); + + var sourceResource = await CreateResource(client, + namespaceName: sourceNamespace, + annotations: new ReflectorAnnotationsBuilder() + .WithReflectionAllowed(true) + .WithAllowedNamespacesSelector("reflector-test-env=prod").Build(), + data: new Dictionary { [dataKey] = initialValue }); + + var directReflection = new V1Secret + { + ApiVersion = V1Secret.KubeApiVersion, + Kind = V1Secret.KubeKind, + Metadata = new V1ObjectMeta + { + Name = sourceResource.Name(), + NamespaceProperty = targetNamespace, + Annotations = new Dictionary + { + [$"{ES.Kubernetes.Reflector.Mirroring.Core.Annotations.Prefix}/reflects"] + = $"{sourceNamespace}/{sourceResource.Name()}" + } + }, + StringData = new Dictionary { [dataKey] = "placeholder" }, + Type = "Opaque" + }; + await client.CoreV1.CreateNamespacedSecretAsync(directReflection, targetNamespace, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(await WaitForSecretDataValue(client, sourceResource.Name(), targetNamespace, + dataKey, initialValue, TestContext.Current.CancellationToken)); + + await PatchNamespaceLabelsAsync(client, targetNamespace, + new Dictionary { ["reflector-test-env"] = "staging" }); + + await DelayForReflection(); + + var patch = new V1Patch( + new { stringData = new Dictionary { [dataKey] = updatedValue } }, + V1Patch.PatchType.MergePatch); + await client.CoreV1.PatchNamespacedSecretAsync(patch, sourceResource.Name(), sourceNamespace, + cancellationToken: TestContext.Current.CancellationToken); + + await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + var mirror = await client.CoreV1.ReadNamespacedSecretAsync(sourceResource.Name(), targetNamespace, + cancellationToken: TestContext.Current.CancellationToken); + var actual = System.Text.Encoding.UTF8.GetString(mirror.Data[dataKey]); + Assert.Equal(initialValue, actual); + } + + [Fact] public async Task AutoReflect_Remove_ReflectionsWhenResourceDeleted() { @@ -168,6 +295,36 @@ private async Task WaitForResource(IKubernetes client, string name, string } + private async Task WaitForResourceAbsent(IKubernetes client, string name, string namespaceName, + CancellationToken cancellationToken = default) + { + return await ResourceAbsentResiliencePipeline.ExecuteAsync(async token => + await ResourceExists(client, name, namespaceName, token), cancellationToken) == false; + } + + + private async Task WaitForSecretDataValue(IKubernetes client, string name, string namespaceName, + string dataKey, string expected, CancellationToken cancellationToken = default) + { + return await ResourceExistsResiliencePipeline.ExecuteAsync(async token => + { + var secret = await client.CoreV1.ReadNamespacedSecretAsync(name, namespaceName, cancellationToken: token); + if (secret.Data is null || !secret.Data.TryGetValue(dataKey, out var bytes)) return false; + return System.Text.Encoding.UTF8.GetString(bytes) == expected; + }, cancellationToken); + } + + + private static async Task PatchNamespaceLabelsAsync(IKubernetes client, string namespaceName, + IDictionary labels) + { + var patch = new V1Patch( + new { metadata = new { labels } }, + V1Patch.PatchType.MergePatch); + await client.CoreV1.PatchNamespaceAsync(patch, namespaceName); + } + + private async Task ResourceExists(IKubernetes client, string name, string namespaceName, CancellationToken cancellationToken = default) { diff --git a/tests/ES.Kubernetes.Reflector.Tests/LabelSelectorMatchTests.cs b/tests/ES.Kubernetes.Reflector.Tests/LabelSelectorMatchTests.cs new file mode 100644 index 00000000..1a33253c --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/LabelSelectorMatchTests.cs @@ -0,0 +1,300 @@ +using ES.Kubernetes.Reflector.Mirroring.Core; +using k8s.Models; + +namespace ES.Kubernetes.Reflector.Tests; + +public class LabelSelectorMatchTests +{ + private static V1Namespace CreateNamespace(string name, Dictionary? labels = null) => + new() + { + Metadata = new V1ObjectMeta + { + Name = name, + Labels = labels ?? new Dictionary() + } + }; + + [Fact] + public void EmptySelector_MatchesAll() + { + var ns = CreateNamespace("test"); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("", ns)); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch(" ", ns)); + } + + [Fact] + public void EqualitySelector_Matches() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env=production", ns)); + } + + [Fact] + public void EqualitySelector_DoesNotMatch() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "staging" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("env=production", ns)); + } + + [Fact] + public void DoubleEqualitySelector_Matches() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env==production", ns)); + } + + [Fact] + public void InequalitySelector_Matches() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "staging" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env!=production", ns)); + } + + [Fact] + public void InequalitySelector_DoesNotMatch() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("env!=production", ns)); + } + + [Fact] + public void InequalitySelector_MissingLabel_Matches() + { + var ns = CreateNamespace("test"); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env!=production", ns)); + } + + [Fact] + public void ExistsSelector_Matches() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env", ns)); + } + + [Fact] + public void ExistsSelector_DoesNotMatch() + { + var ns = CreateNamespace("test"); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("env", ns)); + } + + [Fact] + public void NotExistsSelector_Matches() + { + var ns = CreateNamespace("test"); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("!env", ns)); + } + + [Fact] + public void NotExistsSelector_DoesNotMatch() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("!env", ns)); + } + + [Fact] + public void MultipleSelectors_AllMustMatch() + { + var ns = CreateNamespace("test", + new Dictionary { { "env", "production" }, { "tier", "frontend" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env=production,tier=frontend", ns)); + } + + [Fact] + public void MultipleSelectors_OneFails() + { + var ns = CreateNamespace("test", + new Dictionary { { "env", "production" }, { "tier", "backend" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("env=production,tier=frontend", ns)); + } + + [Fact] + public void InSelector_Matches() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "staging" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env in (production,staging)", ns)); + } + + [Fact] + public void InSelector_DoesNotMatch() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "dev" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("env in (production,staging)", ns)); + } + + [Fact] + public void NotInSelector_Matches() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "dev" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch("env notin (production,staging)", ns)); + } + + [Fact] + public void NotInSelector_DoesNotMatch() + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch("env notin (production,staging)", ns)); + } + + [Theory] + [InlineData(",")] + [InlineData(",,")] + [InlineData(", ,")] + public void InvalidSelector_CommasOnly_FailsClosed(string selector) + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch(selector, ns)); + } + + [Theory] + [InlineData("!")] + [InlineData("!=value")] + [InlineData("=value")] + public void InvalidSelector_EmptyKey_FailsClosed(string selector) + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch(selector, ns)); + } + + [Theory] + [InlineData("env in (prod")] + [InlineData("env in prod)")] + [InlineData("env in ()")] + [InlineData("()")] + [InlineData("=")] + public void InvalidSelector_MalformedExpression_FailsClosed(string selector) + { + var ns = CreateNamespace("test", new Dictionary { { "env", "production" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch(selector, ns)); + } + + [Fact] + public void MirroringProperties_AllowedNamespacesSelector_MatchesByLabel() + { + var ns = CreateNamespace("my-ns", new Dictionary { { "env", "production" } }); + var props = new MirroringProperties + { + Allowed = true, + AllowedNamespacesSelector = "env=production" + }; + Assert.True(props.CanBeReflectedToNamespace(ns)); + } + + [Fact] + public void MirroringProperties_AllowedNamespacesSelector_DoesNotMatchByLabel() + { + var ns = CreateNamespace("my-ns", new Dictionary { { "env", "staging" } }); + var props = new MirroringProperties + { + Allowed = true, + AllowedNamespaces = "other-ns", + AllowedNamespacesSelector = "env=production" + }; + Assert.False(props.CanBeReflectedToNamespace(ns)); + } + + [Fact] + public void MirroringProperties_OrLogic_NameMatchWins() + { + var ns = CreateNamespace("my-ns", new Dictionary { { "env", "staging" } }); + var props = new MirroringProperties + { + Allowed = true, + AllowedNamespaces = "my-ns", + AllowedNamespacesSelector = "env=production" + }; + // Name matches even though label doesn't + Assert.True(props.CanBeReflectedToNamespace(ns)); + } + + [Fact] + public void MirroringProperties_OrLogic_LabelMatchWins() + { + var ns = CreateNamespace("my-ns", new Dictionary { { "env", "production" } }); + var props = new MirroringProperties + { + Allowed = true, + AllowedNamespaces = "other-ns", + AllowedNamespacesSelector = "env=production" + }; + // Label matches even though name doesn't + Assert.True(props.CanBeReflectedToNamespace(ns)); + } + + [Fact] + public void MirroringProperties_AutoNamespacesSelector() + { + var ns = CreateNamespace("my-ns", new Dictionary { { "env", "production" } }); + var props = new MirroringProperties + { + Allowed = true, + AutoEnabled = true, + AutoNamespacesSelector = "env=production" + }; + Assert.True(props.CanBeAutoReflectedToNamespace(ns)); + } + + [Theory] + [InlineData("-env=prod")] + [InlineData("env-=prod")] + [InlineData(".env=prod")] + [InlineData("env name=prod")] + public void InvalidSelector_InvalidLabelKey_FailsClosed(string selector) + { + var ns = CreateNamespace("test", new Dictionary { { "env", "prod" } }); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch(selector, ns)); + } + + [Fact] + public void InvalidSelector_LabelKeyTooLong_FailsClosed() + { + var longName = new string('a', 64); + var ns = CreateNamespace("test"); + Assert.False(MirroringPropertiesExtensions.LabelSelectorMatch($"{longName}=value", ns)); + } + + [Fact] + public void ValidSelector_PrefixedLabelKey_Matches() + { + var ns = CreateNamespace("test", + new Dictionary { { "app.kubernetes.io/name", "reflector" } }); + Assert.True(MirroringPropertiesExtensions.LabelSelectorMatch( + "app.kubernetes.io/name=reflector", ns)); + } + + [Fact] + public void GetLabelSelectorErrors_ValidSelectors_ReturnsEmpty() + { + var props = new MirroringProperties + { + AllowedNamespacesSelector = "env=prod", + AutoNamespacesSelector = "tier in (frontend,backend)" + }; + Assert.Empty(props.GetLabelSelectorErrors()); + } + + [Fact] + public void GetLabelSelectorErrors_InvalidSelector_ReturnsErrors() + { + var props = new MirroringProperties + { + AllowedNamespacesSelector = "=prod", + AutoNamespacesSelector = "valid=value" + }; + var errors = props.GetLabelSelectorErrors(); + Assert.NotEmpty(errors); + Assert.Contains(errors, e => e.Contains("reflection-allowed-namespaces-selector")); + } + + [Fact] + public void GetLabelSelectorErrors_EmptySelectors_ReturnsEmpty() + { + var props = new MirroringProperties + { + AllowedNamespacesSelector = string.Empty, + AutoNamespacesSelector = string.Empty + }; + Assert.Empty(props.GetLabelSelectorErrors()); + } +} diff --git a/tests/ES.Kubernetes.Reflector.Tests/NamespaceLabelsEqualTests.cs b/tests/ES.Kubernetes.Reflector.Tests/NamespaceLabelsEqualTests.cs new file mode 100644 index 00000000..3ff1a787 --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/NamespaceLabelsEqualTests.cs @@ -0,0 +1,62 @@ +using ES.Kubernetes.Reflector.Mirroring; +using ES.Kubernetes.Reflector.Mirroring.Core; +using k8s.Models; + +namespace ES.Kubernetes.Reflector.Tests; + +public class NamespaceLabelsEqualTests +{ + private static V1Namespace Ns(Dictionary? labels) => + new() + { + Metadata = new V1ObjectMeta { Name = "ns", Labels = labels } + }; + + [Fact] + public void NullAndEmpty_AreEqual() + { + Assert.True(ResourceMirror.NamespaceLabelsEqual(Ns(null), Ns(new()))); + Assert.True(ResourceMirror.NamespaceLabelsEqual(Ns(new()), Ns(null))); + Assert.True(ResourceMirror.NamespaceLabelsEqual(Ns(null), Ns(null))); + } + + [Fact] + public void SameLabels_AreEqual() + { + var a = Ns(new() { ["env"] = "prod", ["tier"] = "frontend" }); + var b = Ns(new() { ["env"] = "prod", ["tier"] = "frontend" }); + Assert.True(ResourceMirror.NamespaceLabelsEqual(a, b)); + } + + [Fact] + public void ChangedValue_IsNotEqual() + { + var a = Ns(new() { ["env"] = "prod" }); + var b = Ns(new() { ["env"] = "staging" }); + Assert.False(ResourceMirror.NamespaceLabelsEqual(a, b)); + } + + [Fact] + public void AddedLabel_IsNotEqual() + { + var a = Ns(new() { ["env"] = "prod" }); + var b = Ns(new() { ["env"] = "prod", ["tier"] = "frontend" }); + Assert.False(ResourceMirror.NamespaceLabelsEqual(a, b)); + } + + [Fact] + public void RemovedLabel_IsNotEqual() + { + var a = Ns(new() { ["env"] = "prod", ["tier"] = "frontend" }); + var b = Ns(new() { ["env"] = "prod" }); + Assert.False(ResourceMirror.NamespaceLabelsEqual(a, b)); + } + + [Fact] + public void RenamedKey_IsNotEqual() + { + var a = Ns(new() { ["env"] = "prod" }); + var b = Ns(new() { ["environment"] = "prod" }); + Assert.False(ResourceMirror.NamespaceLabelsEqual(a, b)); + } +}