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: