Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<list>"` 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: "<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: "<list>"` 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: "<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.

Expand All @@ -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
Expand All @@ -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: "<source namespace>/<source name>"` to the mirror object. The value of the annotation is the full name of the source object in `namespace/name` format.
Expand Down
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,120 @@ 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 (=, ==, !=), set-based (in, notin), and existence-based (key, !key) selectors.
/// 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);

// Fail closed: a non-empty selector that produces no valid requirements is invalid
if (requirements.Count == 0) return false;

foreach (var raw in requirements)
{
var requirement = raw.Trim();
if (string.IsNullOrEmpty(requirement)) continue;

// 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();
if (string.IsNullOrEmpty(key)) return false;
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();
if (string.IsNullOrEmpty(key)) return false;
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 (string.IsNullOrEmpty(key)) return false;
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;
}
}
Loading
Loading