diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 09a252a..ae245c8 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -4,16 +4,19 @@ on: push: paths: - "src/**" + - "tests/**" - ".github/workflows/**" pull_request: paths: - "src/**" + - "tests/**" - ".github/workflows/**" env: version: 9.0.${{github.run_number}} imageRepository: "emberstack/kubernetes-reflector" DOCKER_CLI_EXPERIMENTAL: "enabled" + DOTNET_VERSION: "9.0.x" jobs: ci: @@ -22,6 +25,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 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/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 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 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 diff --git a/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs b/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..b4933bc --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs @@ -0,0 +1,69 @@ +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 + 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 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..eb6a048 --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + enable + enable + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + 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 diff --git a/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs b/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs new file mode 100644 index 0000000..588725f --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/K8SResourceAssertionExtensions.cs @@ -0,0 +1,84 @@ +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 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 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 diff --git a/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs new file mode 100644 index 0000000..105999b --- /dev/null +++ b/tests/ES.Kubernetes.Reflector.Tests/SecretMirrorTests.cs @@ -0,0 +1,129 @@ +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"); + })); + } + + [Fact] + public async Task Create_secret_With_DefaultReflectorAnnotations_Should_Replicated_To_All_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