Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="ES.Kubernetes.Reflector.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="ES.FX.Ignite" />
<PackageReference Include="ES.FX.Ignite.OpenTelemetry.Exporter.Seq" />
Expand Down
2 changes: 2 additions & 0 deletions src/ES.Kubernetes.Reflector/Mirroring/Core/Annotations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +24,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,

Expand All @@ -32,6 +38,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
Expand All @@ -55,14 +67,54 @@ public static MirroringProperties GetMirroringProperties(this V1ObjectMeta metad
: null
};

/// <summary>
/// Checks if the source properties allow reflection to the given namespace (by name only).
/// Use the overload accepting V1Namespace for label selector support.
/// </summary>
public static bool CanBeReflectedToNamespace(this MirroringProperties properties, string ns) =>
properties.Allowed && PatternListMatch(properties.AllowedNamespaces, ns);


/// <summary>
/// Checks if the source properties allow reflection to the given namespace,
/// including label selector matching when a V1Namespace object is available.
/// </summary>
public static bool CanBeReflectedToNamespace(this MirroringProperties properties, V1Namespace ns) =>
properties.Allowed && MatchNamespace(properties.AllowedNamespaces, properties.AllowedNamespacesSelector, ns);

/// <summary>
/// Checks if the source properties allow auto-reflection to the given namespace (by name only).
/// Use the overload accepting V1Namespace for label selector support.
/// </summary>
public static bool CanBeAutoReflectedToNamespace(this MirroringProperties properties, string ns) =>
properties.CanBeReflectedToNamespace(ns) && properties.AutoEnabled &&
PatternListMatch(properties.AutoNamespaces, ns);

/// <summary>
/// Checks if the source properties allow auto-reflection to the given namespace,
/// including label selector matching when a V1Namespace object is available.
/// </summary>
public static bool CanBeAutoReflectedToNamespace(this MirroringProperties properties, V1Namespace ns) =>
properties.CanBeReflectedToNamespace(ns) && properties.AutoEnabled &&
MatchNamespace(properties.AutoNamespaces, properties.AutoNamespacesSelector, ns);

/// <summary>
/// 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).
/// </summary>
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)
{
Expand All @@ -72,4 +124,114 @@ private static bool PatternListMatch(string patternList, string value)
.Select(pattern => Regex.Match(value, pattern))
.Any(match => match.Success && match.Value.Length == value.Length);
}
}

/// <summary>
/// Matches a Kubernetes label selector string against namespace labels.
/// Supports equality-based (=, ==, !=) and existence-based (key, !key) selectors.
Comment thread
davidswimbird marked this conversation as resolved.
Outdated
/// Multiple selectors separated by commas are ANDed together.
/// </summary>
internal static bool LabelSelectorMatch(string selector, V1Namespace ns)
Comment thread
davidswimbird marked this conversation as resolved.
{
if (string.IsNullOrWhiteSpace(selector)) return true;

var labels = ns.Metadata?.Labels ?? new Dictionary<string, string>();
var requirements = SplitRequirements(selector);

foreach (var raw in requirements)
{
var requirement = raw.Trim();
if (string.IsNullOrEmpty(requirement)) continue;
Comment thread
davidswimbird marked this conversation as resolved.
Outdated

// Handle set-based: key in (v1,v2) / key notin (v1,v2)
if (TryParseSetBased(requirement, labels, out var setResult))
{
if (!setResult) return false;
continue;
}

// Handle != (must check before =)
if (requirement.Contains("!="))
{
var parts = requirement.Split("!=", 2);
var key = parts[0].Trim();
var value = parts[1].Trim();
if (labels.TryGetValue(key, out var labelValue) && labelValue == value) return false;
continue;
}

// Handle == or =
var eqIndex = requirement.IndexOf("==", StringComparison.Ordinal);
if (eqIndex < 0) eqIndex = requirement.IndexOf('=');
if (eqIndex > 0)
{
var key = requirement[..eqIndex].TrimEnd('=').Trim();
var value = requirement[(eqIndex + (requirement[eqIndex..].StartsWith("==") ? 2 : 1))..].Trim();
if (!labels.TryGetValue(key, out var labelValue) || labelValue != value) return false;
continue;
}

// Handle !key (not exists)
if (requirement.StartsWith('!'))
{
var key = requirement[1..].Trim();
if (labels.ContainsKey(key)) return false;
continue;
}

// Handle key (exists)
if (!labels.ContainsKey(requirement)) return false;
}

return true;
}

private static List<string> SplitRequirements(string selector)
{
var results = new List<string>();
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, IDictionary<string, string> labels, out bool result)
{
result = false;

// Match "key in (v1,v2)" or "key notin (v1,v2)"
var match = Regex.Match(requirement, @"^([a-zA-Z0-9_./-]+)\s+(in|notin)\s+\(([^)]*)\)$");
Comment thread
davidswimbird marked this conversation as resolved.
Outdated
if (!match.Success) return false;

var key = match.Groups[1].Value;
var op = match.Groups[2].Value;
var values = match.Groups[3].Value
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(v => v.Trim())
.ToHashSet();

labels.TryGetValue(key, out var labelValue);

result = op switch
{
"in" => labelValue != null && values.Contains(labelValue),
"notin" => labelValue == null || !values.Contains(labelValue),
_ => false
};

return true;
}
}
36 changes: 29 additions & 7 deletions src/ES.Kubernetes.Reflector/Mirroring/Core/ResourceMirror.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public abstract class ResourceMirror<TResource>(ILogger logger, IKubernetes kube
private readonly ConcurrentDictionary<NamespacedName, bool> _autoSources = new();
private readonly ConcurrentDictionary<NamespacedName, HashSet<NamespacedName>> _directReflectionCache = new();

private readonly ConcurrentDictionary<string, V1Namespace> _namespaceCache = new();
private readonly ConcurrentDictionary<NamespacedName, bool> _notFoundCache = new();
private readonly ConcurrentDictionary<NamespacedName, MirroringProperties> _propertiesCache = new();
protected readonly IKubernetes Kubernetes = kubernetes;
Expand All @@ -38,6 +39,7 @@ 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();
Expand Down Expand Up @@ -108,13 +110,16 @@ public async Task Handle(WatcherEvent notification, CancellationToken cancellati
Logger.LogTrace("Handling {eventType} {resourceType} {resourceRef}", notification.EventType, ns.Kind,
ns.ObjectReference().NamespacedName());

//Cache the namespace for label selector lookups
_namespaceCache.AddOrUpdate(ns.Name(), ns, (_, _) => ns);

Comment thread
davidswimbird marked this conversation as resolved.
Outdated
//Update all auto-sources
foreach (var sourceNsName in _autoSources.Keys)
{
var properties = _propertiesCache[sourceNsName];

//If it can't be reflected to this namespace, skip
if (!properties.CanBeAutoReflectedToNamespace(ns.Name())) continue;
if (!properties.CanBeAutoReflectedToNamespace(ns)) continue;


//Get the list of auto-reflections
Expand Down Expand Up @@ -155,7 +160,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))
Comment thread
davidswimbird marked this conversation as resolved.
.ToHashSet();

foreach (var reflectionNsName in reflections)
Expand All @@ -172,7 +177,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)
{
Expand Down Expand Up @@ -263,7 +268,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);
Expand Down Expand Up @@ -326,7 +331,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}.",
Expand Down Expand Up @@ -354,16 +359,23 @@ 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();
_propertiesCache.AddOrUpdate(match.ObjectReference().NamespacedName(),
_ => 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();
Expand All @@ -377,7 +389,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
Expand Down Expand Up @@ -557,4 +569,14 @@ private async Task ResourceReflect(NamespacedName sourceNsName, NamespacedName r
protected abstract Task<TResource> OnResourceGet(NamespacedName refId);

protected virtual Task<bool> OnResourceIgnoreCheck(TResource item) => Task.FromResult(false);

private bool CanBeReflectedToNamespaceCached(MirroringProperties properties, string ns) =>
_namespaceCache.TryGetValue(ns, out var nsObj)
? properties.CanBeReflectedToNamespace(nsObj)
: properties.CanBeReflectedToNamespace(ns);
Comment thread
davidswimbird marked this conversation as resolved.
Outdated

private bool CanBeAutoReflectedToNamespaceCached(MirroringProperties properties, string ns) =>
_namespaceCache.TryGetValue(ns, out var nsObj)
? properties.CanBeAutoReflectedToNamespace(nsObj)
: properties.CanBeAutoReflectedToNamespace(ns);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string, string> Build()
{
if (_annotations.Count != 0) return _annotations;
Expand Down
Loading
Loading