From 3d352f1b90b73622d6c673cb63b8a24625aa3f49 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:11:04 +0100
Subject: [PATCH 01/16] refactor: add partial class declaration for Program
---
src/ES.Kubernetes.Reflector/Program.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/ES.Kubernetes.Reflector/Program.cs b/src/ES.Kubernetes.Reflector/Program.cs
index b2fdee8..2c0de80 100644
--- a/src/ES.Kubernetes.Reflector/Program.cs
+++ b/src/ES.Kubernetes.Reflector/Program.cs
@@ -39,4 +39,6 @@
app.Ignite();
await app.RunAsync();
return 0;
-});
\ No newline at end of file
+});
+
+public partial class Program { }
\ No newline at end of file
From fa0ed7edce92c8789dc3bca2856fda7ce19da292 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:14:30 +0100
Subject: [PATCH 02/16] chore: add test project
---
src/ES.Kubernetes.Reflector.sln | 6 +++
.../ES.Kubernetes.Reflector.Tests.csproj | 37 +++++++++++++++++++
2 files changed, 43 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj
diff --git a/src/ES.Kubernetes.Reflector.sln b/src/ES.Kubernetes.Reflector.sln
index cbc9938..020d641 100644
--- a/src/ES.Kubernetes.Reflector.sln
+++ b/src/ES.Kubernetes.Reflector.sln
@@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "....Solution Items", "....S
NuGet.config = NuGet.config
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ES.Kubernetes.Reflector.Tests", "..\tests\ES.Kubernetes.Reflector.Tests\ES.Kubernetes.Reflector.Tests.csproj", "{19FBB55B-53C8-4EB1-9B76-34B69498E3A4}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -22,6 +24,10 @@ Global
{96CDE0CF-7782-490B-8AF6-4219DB0236B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{96CDE0CF-7782-490B-8AF6-4219DB0236B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{96CDE0CF-7782-490B-8AF6-4219DB0236B3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {19FBB55B-53C8-4EB1-9B76-34B69498E3A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {19FBB55B-53C8-4EB1-9B76-34B69498E3A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {19FBB55B-53C8-4EB1-9B76-34B69498E3A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {19FBB55B-53C8-4EB1-9B76-34B69498E3A4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj b/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj
new file mode 100644
index 0000000..96f2797
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj
@@ -0,0 +1,37 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
From 33ecd391aa030b99ae477cfb6da65fb5a7fa42ba Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:21:49 +0100
Subject: [PATCH 03/16] feat(tests): implement CustomWebApplicationFactory for
Kubernetes integration tests
---
.../CustomWebApplicationFactory.cs | 76 +++++++++++++++++++
1 file changed, 76 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs b/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
new file mode 100644
index 0000000..9b567a7
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
@@ -0,0 +1,76 @@
+using ES.Kubernetes.Reflector.Configuration;
+using k8s;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Testcontainers.K3s;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime
+{
+ private readonly K3sContainer _container = new K3sBuilder()
+ .WithImage("rancher/k3s:v1.26.2-k3s1")
+ .Build();
+ private static readonly Lock Lock = new();
+
+ ///
+ /// https://github.com/serilog/serilog-aspnetcore/issues/289
+ /// https://github.com/dotnet/AspNetCore.Docs/issues/26609
+ /// There is a problem with using Serilog's "CreateBootstrapLogger" when trying to initialize a web host.
+ /// This is because in tests, multiple hosts are created in parallel, and Serilog's static logger is not thread-safe.
+ /// The way around this without touching the host code is to lock the creation of the host to a single thread at a time.
+ ///
+ ///
+ ///
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ lock (Lock)
+ return base.CreateHost(builder);
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ var kubeConfigContent = _container.GetKubeconfigAsync().GetAwaiter().GetResult();
+ if (string.IsNullOrWhiteSpace(kubeConfigContent))
+ {
+ throw new InvalidOperationException("Kubeconfig content is empty");
+ }
+
+ builder.ConfigureServices(services =>
+ {
+ // remove the existing KubernetesClientConfiguration and IKubernetes registrations
+ var kubernetesClientConfiguration = services.SingleOrDefault(
+ d => d.ServiceType == typeof(KubernetesClientConfiguration));
+ if (kubernetesClientConfiguration is not null)
+ {
+ services.Remove(kubernetesClientConfiguration);
+ }
+
+ services.AddSingleton(s =>
+ {
+ var reflectorOptions = s.GetRequiredService>();
+
+ // create config file on disk file from _kubeConfigContent
+ var tempFile = Path.GetTempFileName();
+ File.WriteAllText(tempFile, kubeConfigContent);
+
+ var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(tempFile);
+ config.HttpClientTimeout = TimeSpan.FromMinutes(30);
+
+ return config;
+ });
+
+ services.AddSingleton(s =>
+ new k8s.Kubernetes(s.GetRequiredService()));
+ });
+ }
+
+ public Task InitializeAsync()
+ => _container.StartAsync();
+
+ public new Task DisposeAsync()
+ => _container.DisposeAsync().AsTask();
+}
\ No newline at end of file
From 38dfba95f4b8a3716e49ace50d6698090644fc5e Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:22:52 +0100
Subject: [PATCH 04/16] feat(tests): add BaseIntegrationTest
---
.../BaseIntegrationTest.cs | 99 +++++++++++++++++++
1 file changed, 99 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/BaseIntegrationTest.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/BaseIntegrationTest.cs b/tests/ES.Kubernetes.Reflector.Tests/BaseIntegrationTest.cs
new file mode 100644
index 0000000..6b82324
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/BaseIntegrationTest.cs
@@ -0,0 +1,99 @@
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Polly;
+using Polly.Retry;
+using ES.FX.Ignite.Configuration;
+using k8s.Autorest;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public abstract class BaseIntegrationTest : IClassFixture
+{
+ protected readonly HttpClient Client;
+ protected readonly IKubernetes K8SClient;
+ protected readonly ResiliencePipeline Pipeline;
+ protected readonly IgniteSettings IgniteSettings;
+
+ protected BaseIntegrationTest(CustomWebApplicationFactory factory)
+ {
+ Client = factory.CreateClient();
+ var scope = factory.Services.CreateScope();
+ K8SClient = scope.ServiceProvider.GetRequiredService();
+ IgniteSettings = scope.ServiceProvider.GetRequiredService();
+
+ // Polly retry and timeout policy to fetch replicated resources
+ Pipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new RetryStrategyOptions
+ {
+ ShouldHandle = new PredicateBuilder()
+ .Handle(ex =>
+ ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ .HandleResult(false),
+ MaxRetryAttempts = 5,
+ Delay = TimeSpan.FromSeconds(2),
+ })
+ .AddTimeout(TimeSpan.FromSeconds(30))
+ .Build();
+ }
+
+ protected async Task CreateNamespaceAsync(string name)
+ {
+ var ns = new V1Namespace
+ {
+ ApiVersion = V1Namespace.KubeApiVersion,
+ Kind = V1Namespace.KubeKind,
+ Metadata = new V1ObjectMeta
+ {
+ Name = name
+ }
+ };
+
+ return await K8SClient.CoreV1.CreateNamespaceAsync(ns);
+ }
+
+ protected async Task CreateConfigMapAsync(
+ string configMapName,
+ IDictionary data,
+ string destinationNamespace,
+ ReflectorAnnotations reflectionAnnotations)
+ {
+ var configMap = new V1ConfigMap
+ {
+ ApiVersion = V1ConfigMap.KubeApiVersion,
+ Kind = V1ConfigMap.KubeKind,
+ Metadata = new V1ObjectMeta
+ {
+ Name = configMapName,
+ NamespaceProperty = destinationNamespace,
+ Annotations = reflectionAnnotations.Build()
+ },
+ Data = data
+ };
+
+ return await K8SClient.CoreV1.CreateNamespacedConfigMapAsync(configMap, destinationNamespace);
+ }
+
+ protected async Task CreateSecretAsync(
+ string secretName,
+ IDictionary data,
+ string destinationNamespace,
+ ReflectorAnnotations reflectionAnnotations)
+ {
+ var secret = new V1Secret
+ {
+ ApiVersion = V1Secret.KubeApiVersion,
+ Kind = V1Secret.KubeKind,
+ Metadata = new V1ObjectMeta
+ {
+ Name = secretName,
+ NamespaceProperty = destinationNamespace,
+ Annotations = reflectionAnnotations.Build()
+ },
+ StringData = data,
+ Type = "Opaque"
+ };
+
+ return await K8SClient.CoreV1.CreateNamespacedSecretAsync(secret, destinationNamespace);
+ }
+}
\ No newline at end of file
From c3a45b8830e122d393acf6896ea4d12e21cec5fd Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:23:05 +0100
Subject: [PATCH 05/16] feat(tests): add ReflectorAnnotations class for
managing annotations
---
.../ReflectorAnnotations.cs | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/ReflectorAnnotations.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/ReflectorAnnotations.cs b/tests/ES.Kubernetes.Reflector.Tests/ReflectorAnnotations.cs
new file mode 100644
index 0000000..b6a830d
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/ReflectorAnnotations.cs
@@ -0,0 +1,45 @@
+using ES.Kubernetes.Reflector.Mirroring.Core;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public sealed class ReflectorAnnotations
+{
+ private readonly Dictionary _annotations = new();
+
+ public ReflectorAnnotations WithReflectionAllowed(bool allowed)
+ {
+ _annotations[Annotations.Reflection.Allowed] = allowed.ToString().ToLower();
+ return this;
+ }
+
+ public ReflectorAnnotations WithAllowedNamespaces(params string[] namespaces)
+ {
+ _annotations[Annotations.Reflection.AllowedNamespaces] = string.Join(",", namespaces);
+ return this;
+ }
+
+ public ReflectorAnnotations WithAutoEnabled(bool enabled)
+ {
+ _annotations[Annotations.Reflection.AutoEnabled] = enabled.ToString().ToLower();
+ return this;
+ }
+
+ public ReflectorAnnotations WithAutoNamespaces(bool enabled, params string[] namespaces)
+ {
+ _annotations[Annotations.Reflection.AutoNamespaces] = enabled ? string.Join(",", namespaces) : string.Empty;
+ return this;
+ }
+
+ public Dictionary Build()
+ {
+ if (_annotations.Count == 0)
+ {
+ _annotations[Annotations.Reflection.Allowed] = "true";
+ _annotations[Annotations.Reflection.AllowedNamespaces] = string.Empty;
+ _annotations[Annotations.Reflection.AutoEnabled] = "true";
+ _annotations[Annotations.Reflection.AutoNamespaces] = string.Empty;
+ }
+
+ return _annotations;
+ }
+}
\ No newline at end of file
From 87c4968654320f8f09794cf55d12bd5b3753783b Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:23:14 +0100
Subject: [PATCH 06/16] feat(tests): add K8SResourceAssertionExtensions for
Kubernetes resource assertions
---
.../K8SResourceAssertionExtensions.cs | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs b/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs
new file mode 100644
index 0000000..f5b0a4c
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs
@@ -0,0 +1,93 @@
+using System.Diagnostics.CodeAnalysis;
+using k8s;
+using k8s.Models;
+using Polly;
+using Shouldly;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public static class K8SResourceAssertionExtensions
+{
+ public static void ShouldBeCreated([NotNull] this T? resource, string metadataName)
+ where T : class, IKubernetesObject
+ {
+ resource.ShouldNotBeNull();
+ resource.Metadata.ShouldNotBeNull();
+ resource.Metadata.Name.ShouldBe(metadataName);
+ }
+
+ public static void ShouldBeDeleted([NotNull] this T resource, V1Status deletionStatus)
+ where T : class, IKubernetesObject
+ {
+ resource.ShouldNotBeNull();
+ deletionStatus.ShouldNotBeNull();
+ deletionStatus.Status.ShouldBe("Success");
+ }
+
+
+ public static async Task ShouldFindReplicatedResourceAsync(this IKubernetes client,
+ T resource,
+ string namespaceName,
+ ResiliencePipeline pipeline,
+ CancellationToken cancellationToken = default)
+ where T : class, IKubernetesObject
+ {
+ var result = await pipeline.ExecuteAsync(async token =>
+ {
+ IKubernetesObject? retrievedResource = resource switch
+ {
+ V1ConfigMap => await client.CoreV1.ReadNamespacedConfigMapAsync(
+ resource.Metadata.Name,
+ namespaceName,
+ cancellationToken: token),
+
+ V1Secret => await client.CoreV1.ReadNamespacedSecretAsync(
+ resource.Metadata.Name,
+ namespaceName,
+ cancellationToken: token),
+
+ _ => throw new NotSupportedException($"Resource type {typeof(T).Name} is not supported")
+ };
+
+ if (retrievedResource is null)
+ return false;
+
+ return (resource, retrievedResource) switch
+ {
+ (V1ConfigMap sourceConfigMap, V1ConfigMap replicatedConfigMap) =>
+ sourceConfigMap.Data.IsEqualTo(replicatedConfigMap.Data),
+
+ (V1Secret sourceSecret, V1Secret replicatedSecret) =>
+ sourceSecret.Data.IsEqualTo(replicatedSecret.Data),
+
+ _ => false
+ };
+ }, cancellationToken);
+
+ result.ShouldBeTrue();
+ }
+
+ private static bool IsEqualTo(this IDictionary? dict1, IDictionary? dict2)
+ where TKey : notnull
+ {
+ if (dict1 == null && dict2 == null)
+ return true;
+
+ if (dict1 == null || dict2 == null)
+ return false;
+
+ if (dict1.Count != dict2.Count)
+ return false;
+
+ return dict1.All(kvp => dict2.TryGetValue(kvp.Key, out var value) &&
+ AreValuesEqual(kvp.Value, value));
+ }
+
+ private static bool AreValuesEqual(TValue value1, TValue value2)
+ {
+ if (value1 is byte[] bytes1 && value2 is byte[] bytes2)
+ return bytes1.SequenceEqual(bytes2);
+
+ return EqualityComparer.Default.Equals(value1, value2);
+ }
+}
\ No newline at end of file
From 592802e93f047eec562eaf44508533fe4125eb47 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:26:41 +0100
Subject: [PATCH 07/16] tests: add HealthCheckTests for liveness and readiness
checks
---
.../HealthCheckTests.cs | 25 +++++++++++++++++++
1 file changed, 25 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/HealthCheckTests.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/HealthCheckTests.cs b/tests/ES.Kubernetes.Reflector.Tests/HealthCheckTests.cs
new file mode 100644
index 0000000..2fb615e
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/HealthCheckTests.cs
@@ -0,0 +1,25 @@
+using System.Net;
+using Shouldly;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public class HealthCheckTests(CustomWebApplicationFactory factory) : BaseIntegrationTest(factory)
+{
+ [Fact]
+ public async Task LivenessHealthCheck_Should_Return_Healthy()
+ {
+ var response = await Client.GetAsync(IgniteSettings.HealthChecks.LivenessEndpointPath);
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ response.Content.Headers.ContentType?.MediaType.ShouldBe("text/plain");
+ var content = await response.Content.ReadAsStringAsync();
+ content.ShouldBe("Healthy");
+ }
+
+ [Fact]
+ public async Task ReadinessHealthCheck_Should_Be_Unavailable()
+ {
+ await Task.Delay(TimeSpan.FromSeconds(10));
+ var response = await Client.GetAsync(IgniteSettings.HealthChecks.ReadinessEndpointPath);
+ response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
+ }
+}
\ No newline at end of file
From 5a639c42896d1967fbcb2fd507bfa95b824cf8b2 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:26:58 +0100
Subject: [PATCH 08/16] tests: add ConfigMapMirrorTests for validating
ConfigMap replication
---
.../ConfigMapMirrorTests.cs | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/ConfigMapMirrorTests.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/ConfigMapMirrorTests.cs b/tests/ES.Kubernetes.Reflector.Tests/ConfigMapMirrorTests.cs
new file mode 100644
index 0000000..bdaa5fa
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/ConfigMapMirrorTests.cs
@@ -0,0 +1,85 @@
+using k8s;
+using Xunit.Abstractions;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public class ConfigMapMirrorTests(CustomWebApplicationFactory factory, ITestOutputHelper testOutputHelper)
+ : BaseIntegrationTest(factory)
+{
+ [Fact]
+ public async Task Create_configMap_With_ReflectionEnabled_Should_Replicated_To_Allowed_Namespaces()
+ {
+ // Arrange
+ const string sourceNamespace = "dev1";
+ const string destinationNamespace = "qa";
+ string sourceConfigMap = $"test-configmap-{Guid.NewGuid()}";
+ var configMapData = new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", "value2" }
+ };
+ var reflectorAnnotations = new ReflectorAnnotations()
+ .WithReflectionAllowed(true)
+ .WithAllowedNamespaces(destinationNamespace)
+ .WithAutoEnabled(true);
+
+ var createdSourceNs = await CreateNamespaceAsync(sourceNamespace);
+ createdSourceNs.ShouldBeCreated(sourceNamespace);
+ testOutputHelper.WriteLine($"Namespace {sourceNamespace} created");
+
+ // Act
+ var createdDestinationNs = await CreateNamespaceAsync(destinationNamespace);
+ createdDestinationNs.ShouldBeCreated(destinationNamespace);
+ testOutputHelper.WriteLine($"Namespace {destinationNamespace} created");
+ var createdConfigMap = await CreateConfigMapAsync(
+ sourceConfigMap,
+ configMapData,
+ sourceNamespace,
+ reflectorAnnotations);
+ createdConfigMap.ShouldBeCreated(sourceConfigMap);
+ testOutputHelper.WriteLine($"ConfigMap {sourceConfigMap} created in {sourceNamespace} namespace");
+
+ // Assert
+ await K8SClient.ShouldFindReplicatedResourceAsync(createdConfigMap, destinationNamespace, Pipeline);
+ testOutputHelper.WriteLine($"ConfigMap {sourceConfigMap} found in {destinationNamespace} namespace");
+ }
+
+ [Fact]
+ public async Task Create_configMap_With_DefaultReflectorAnnotations_Should_Replicated_To_All_Namespaces()
+ {
+ // Arrange
+ const string sourceNamespace = "dev2";
+ string sourceConfigMap = $"test-configmap-{Guid.NewGuid()}";
+ var configMapData = new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", "value2" }
+ };
+ var reflectorAnnotations = new ReflectorAnnotations();
+
+ var createdSourceNs = await CreateNamespaceAsync(sourceNamespace);
+ createdSourceNs.ShouldBeCreated(sourceNamespace);
+ testOutputHelper.WriteLine($"Namespace {sourceNamespace} created");
+
+ // Act
+ var createdConfigMap = await CreateConfigMapAsync(
+ sourceConfigMap,
+ configMapData,
+ sourceNamespace,
+ reflectorAnnotations);
+ createdConfigMap.ShouldBeCreated(sourceConfigMap);
+ testOutputHelper.WriteLine($"ConfigMap {sourceConfigMap} created in {sourceNamespace} namespace");
+
+ // Assert
+ var namespaces = await K8SClient.CoreV1.ListNamespaceAsync();
+ var targetNamespaces = namespaces.Items
+ .Where(ns => !string.Equals(ns.Metadata.Name, sourceNamespace, StringComparison.Ordinal))
+ .ToList();
+
+ await Task.WhenAll(targetNamespaces.Select(async ns =>
+ {
+ await K8SClient.ShouldFindReplicatedResourceAsync(createdConfigMap, ns.Metadata.Name, Pipeline);
+ testOutputHelper.WriteLine($"ConfigMap {sourceConfigMap} found in {ns.Metadata.Name} namespace");
+ }));
+ }
+}
\ No newline at end of file
From 068c35d52ecc60677b4f1bd88713cc74cd2fd7a8 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 20:27:07 +0100
Subject: [PATCH 09/16] tests: add SecretMirrorTests for validating secret
replication across namespaces
---
.../SecretMirrorTests.cs | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
diff --git a/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
new file mode 100644
index 0000000..8ef981b
--- /dev/null
+++ b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
@@ -0,0 +1,85 @@
+using k8s;
+using Xunit.Abstractions;
+
+namespace ES.Kubernetes.Reflector.Tests;
+
+public class SecretMirrorTests(CustomWebApplicationFactory factory, ITestOutputHelper testOutputHelper)
+ : BaseIntegrationTest(factory)
+{
+ [Fact]
+ public async Task Create_secret_With_ReflectionEnabled_Should_Replicated_To_Allowed_Namespaces()
+ {
+ // Arrange
+ const string sourceNamespace = "dev002";
+ const string destinationNamespace = "qa002";
+ string sourceSecret = $"test-secret-{Guid.NewGuid()}";
+ var secretData = new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", "value2" }
+ };
+ var reflectorAnnotations = new ReflectorAnnotations()
+ .WithReflectionAllowed(true)
+ .WithAllowedNamespaces(destinationNamespace)
+ .WithAutoEnabled(true);
+
+ var createdSourceNs = await CreateNamespaceAsync(sourceNamespace);
+ createdSourceNs.ShouldBeCreated(sourceNamespace);
+ testOutputHelper.WriteLine($"Namespace {sourceNamespace} created");
+
+ // Act
+ var createdDestinationNs = await CreateNamespaceAsync(destinationNamespace);
+ createdDestinationNs.ShouldBeCreated(destinationNamespace);
+ testOutputHelper.WriteLine($"Namespace {destinationNamespace} created");
+ var createdSecret = await CreateSecretAsync(
+ sourceSecret,
+ secretData,
+ sourceNamespace,
+ reflectorAnnotations);
+ createdSecret.ShouldBeCreated(sourceSecret);
+ testOutputHelper.WriteLine($"Secret {sourceSecret} created in {sourceNamespace} namespace");
+
+ // Assert
+ await K8SClient.ShouldFindReplicatedResourceAsync(createdSecret, destinationNamespace, Pipeline);
+ testOutputHelper.WriteLine($"Secret {sourceSecret} found in {destinationNamespace} namespace");
+ }
+
+ [Fact]
+ public async Task Create_secret_With_DefaultReflectorAnnotations_Should_Replicated_To_All_Namespaces()
+ {
+ // Arrange
+ const string sourceNamespace = "dev003";
+ string sourceSecret = $"test-secret-{Guid.NewGuid()}";
+ var secretData = new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", "value2" }
+ };
+ var reflectorAnnotations = new ReflectorAnnotations();
+
+ var createdSourceNs = await CreateNamespaceAsync(sourceNamespace);
+ createdSourceNs.ShouldBeCreated(sourceNamespace);
+ testOutputHelper.WriteLine($"Namespace {sourceNamespace} created");
+
+ // Act
+ var createdSecret = await CreateSecretAsync(
+ sourceSecret,
+ secretData,
+ sourceNamespace,
+ reflectorAnnotations);
+ createdSecret.ShouldBeCreated(sourceSecret);
+ testOutputHelper.WriteLine($"Secret {sourceSecret} created in {sourceNamespace} namespace");
+
+ // Assert
+ var namespaces = await K8SClient.CoreV1.ListNamespaceAsync();
+ var targetNamespaces = namespaces.Items
+ .Where(ns => !string.Equals(ns.Metadata.Name, sourceNamespace, StringComparison.Ordinal))
+ .ToList();
+ await Task.WhenAll(targetNamespaces.Select(async ns =>
+ {
+ await K8SClient.ShouldFindReplicatedResourceAsync(createdSecret, ns.Metadata.Name, Pipeline);
+ testOutputHelper.WriteLine($"Secret {sourceSecret} found in {ns.Metadata.Name} namespace");
+ }));
+ }
+
+}
\ No newline at end of file
From 6040cd5df2b32192271d8f7481cdacfc28a14eeb Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Wed, 19 Mar 2025 21:01:44 +0100
Subject: [PATCH 10/16] tests: reproduce
https://github.com/emberstack/kubernetes-reflector/issues/478
---
.../SecretMirrorTests.cs | 45 +++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
index 8ef981b..f4b64c3 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
@@ -82,4 +82,49 @@ await Task.WhenAll(targetNamespaces.Select(async ns =>
}));
}
+
+ [Fact]
+ public async Task Create_secret_With_ReflectionEnabled_Should_Replicated_To_Newly_Created_Namespaces()
+ {
+ // Arrange
+ const string sourceNamespace = "dev004";
+ string sourceSecret = $"test-secret-{Guid.NewGuid()}";
+ var secretData = new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", "value2" }
+ };
+ var reflectorAnnotations = new ReflectorAnnotations();
+
+ var createdSourceNs = await CreateNamespaceAsync(sourceNamespace);
+ createdSourceNs.ShouldBeCreated(sourceNamespace);
+ testOutputHelper.WriteLine($"Namespace {sourceNamespace} created");
+
+ var createdSecret = await CreateSecretAsync(
+ sourceSecret,
+ secretData,
+ sourceNamespace,
+ reflectorAnnotations);
+ createdSecret.ShouldBeCreated(sourceSecret);
+ testOutputHelper.WriteLine($"Secret {sourceSecret} created in {sourceNamespace} namespace");
+
+ // Act
+ const string destinationNamespace = "qa004";
+ var createdDestinationNs = await CreateNamespaceAsync(destinationNamespace);
+ createdDestinationNs.ShouldBeCreated(destinationNamespace);
+ testOutputHelper.WriteLine($"Namespace {destinationNamespace} created");
+
+ // Assert
+ await K8SClient.ShouldFindReplicatedResourceAsync(createdSecret, destinationNamespace, Pipeline);
+
+
+ // another namespace
+ const string destinationNamespace2 = "stg004";
+ var createdDestinationNs2 = await CreateNamespaceAsync(destinationNamespace2);
+ createdDestinationNs2.ShouldBeCreated(destinationNamespace2);
+ testOutputHelper.WriteLine($"Namespace {destinationNamespace2} created");
+
+ // Assert
+ await K8SClient.ShouldFindReplicatedResourceAsync(createdSecret, destinationNamespace2, Pipeline);
+ }
}
\ No newline at end of file
From d93cd0664d03af57eff5f2ea2e711b43d87e93ed Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Thu, 20 Mar 2025 07:51:44 +0100
Subject: [PATCH 11/16] tests: rename test for clarity on default reflector
annotations in SecretMirrorTests
---
tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
index f4b64c3..105999b 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs
@@ -82,9 +82,8 @@ await Task.WhenAll(targetNamespaces.Select(async ns =>
}));
}
-
[Fact]
- public async Task Create_secret_With_ReflectionEnabled_Should_Replicated_To_Newly_Created_Namespaces()
+ public async Task Create_secret_With_DefaultReflectorAnnotations_Should_Replicated_To_All_Newly_Created_Namespaces()
{
// Arrange
const string sourceNamespace = "dev004";
From da56063023458882bb6184edd0de762761da1cd8 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Thu, 20 Mar 2025 07:55:17 +0100
Subject: [PATCH 12/16] refactor: clean up
---
.../ES.Kubernetes.Reflector.Tests.csproj | 1 -
1 file changed, 1 deletion(-)
diff --git a/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj b/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj
index 96f2797..eb6a048 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj
+++ b/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj
@@ -4,7 +4,6 @@
net9.0
enable
enable
-
false
true
From 62fee6ce3358b483cb2269b74e8c9452d7221f66 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Thu, 20 Mar 2025 08:04:08 +0100
Subject: [PATCH 13/16] chore(ci): add .NET setup and test steps to pipeline
---
.github/workflows/pipeline.yaml | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml
index 09a252a..5268d88 100644
--- a/.github/workflows/pipeline.yaml
+++ b/.github/workflows/pipeline.yaml
@@ -14,6 +14,7 @@ env:
version: 9.0.${{github.run_number}}
imageRepository: "emberstack/kubernetes-reflector"
DOCKER_CLI_EXPERIMENTAL: "enabled"
+ DOTNET_VERSION: "9.x"
jobs:
ci:
@@ -22,6 +23,15 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Test
+ run: dotnet test -c Release --verbosity normal
+ working-directory: ./tests/ES.Kubernetes.Reflector.Tests
+
- name: artifacts - prepare directories
run: |
mkdir -p .artifacts/helm
From eb6861311987cd141236716ae0e19c0c8a3fb510 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Thu, 20 Mar 2025 08:06:34 +0100
Subject: [PATCH 14/16] refactor: clean up comments and remove unused method in
K8SResourceAssertionExtensions
---
.../CustomWebApplicationFactory.cs | 11 ++---------
.../K8SResourceAssertionExtensions.cs | 11 +----------
2 files changed, 3 insertions(+), 19 deletions(-)
diff --git a/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs b/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
index 9b567a7..b4933bc 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
@@ -16,15 +16,8 @@ public class CustomWebApplicationFactory : WebApplicationFactory, IAsyn
.Build();
private static readonly Lock Lock = new();
- ///
- /// https://github.com/serilog/serilog-aspnetcore/issues/289
- /// https://github.com/dotnet/AspNetCore.Docs/issues/26609
- /// There is a problem with using Serilog's "CreateBootstrapLogger" when trying to initialize a web host.
- /// This is because in tests, multiple hosts are created in parallel, and Serilog's static logger is not thread-safe.
- /// The way around this without touching the host code is to lock the creation of the host to a single thread at a time.
- ///
- ///
- ///
+ // https://github.com/serilog/serilog-aspnetcore/issues/289
+ // https://github.com/dotnet/AspNetCore.Docs/issues/26609
protected override IHost CreateHost(IHostBuilder builder)
{
lock (Lock)
diff --git a/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs b/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs
index f5b0a4c..588725f 100644
--- a/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs
+++ b/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs
@@ -15,16 +15,7 @@ public static void ShouldBeCreated([NotNull] this T? resource, string metadat
resource.Metadata.ShouldNotBeNull();
resource.Metadata.Name.ShouldBe(metadataName);
}
-
- public static void ShouldBeDeleted([NotNull] this T resource, V1Status deletionStatus)
- where T : class, IKubernetesObject
- {
- resource.ShouldNotBeNull();
- deletionStatus.ShouldNotBeNull();
- deletionStatus.Status.ShouldBe("Success");
- }
-
-
+
public static async Task ShouldFindReplicatedResourceAsync(this IKubernetes client,
T resource,
string namespaceName,
From 14b017dc285fa6a9d69eb7a60ccaa4d9eb93778a Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Thu, 20 Mar 2025 08:16:35 +0100
Subject: [PATCH 15/16] chore(ci): include test paths in push and pull_request
triggers
---
.github/workflows/pipeline.yaml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml
index 5268d88..f605d4f 100644
--- a/.github/workflows/pipeline.yaml
+++ b/.github/workflows/pipeline.yaml
@@ -4,10 +4,12 @@ on:
push:
paths:
- "src/**"
+ - "tests/**"
- ".github/workflows/**"
pull_request:
paths:
- "src/**"
+ - "tests/**"
- ".github/workflows/**"
env:
From 57e5174686620f99a2ba1ffa53248e8ab87d2390 Mon Sep 17 00:00:00 2001
From: Laurent Egbakou <26142591+egbakou@users.noreply.github.com>
Date: Thu, 20 Mar 2025 18:44:58 +0100
Subject: [PATCH 16/16] chore(ci): update .NET version to 9.0.x in pipeline
configuration
---
.github/workflows/pipeline.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml
index f605d4f..ae245c8 100644
--- a/.github/workflows/pipeline.yaml
+++ b/.github/workflows/pipeline.yaml
@@ -16,7 +16,7 @@ env:
version: 9.0.${{github.run_number}}
imageRepository: "emberstack/kubernetes-reflector"
DOCKER_CLI_EXPERIMENTAL: "enabled"
- DOTNET_VERSION: "9.x"
+ DOTNET_VERSION: "9.0.x"
jobs:
ci: