Skip to content

test: add integration tests and CI pipeline updates #483

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions .github/workflows/pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/ES.Kubernetes.Reflector.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/ES.Kubernetes.Reflector/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@
app.Ignite();
await app.RunAsync();
return 0;
});
});

public partial class Program { }
99 changes: 99 additions & 0 deletions tests/ES.Kubernetes.Reflector.Tests/BaseIntegrationTest.cs
Original file line number Diff line number Diff line change
@@ -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<CustomWebApplicationFactory>
{
protected readonly HttpClient Client;
protected readonly IKubernetes K8SClient;
protected readonly ResiliencePipeline<bool> Pipeline;
protected readonly IgniteSettings IgniteSettings;

protected BaseIntegrationTest(CustomWebApplicationFactory factory)
{
Client = factory.CreateClient();
var scope = factory.Services.CreateScope();
K8SClient = scope.ServiceProvider.GetRequiredService<IKubernetes>();
IgniteSettings = scope.ServiceProvider.GetRequiredService<IgniteSettings>();

// Polly retry and timeout policy to fetch replicated resources
Pipeline = new ResiliencePipelineBuilder<bool>()
.AddRetry(new RetryStrategyOptions<bool>
{
ShouldHandle = new PredicateBuilder<bool>()
.Handle<HttpOperationException>(ex =>
ex.Response.StatusCode == System.Net.HttpStatusCode.NotFound)
.HandleResult(false),
MaxRetryAttempts = 5,
Delay = TimeSpan.FromSeconds(2),
})
.AddTimeout(TimeSpan.FromSeconds(30))
.Build();
}

protected async Task<V1Namespace?> 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<V1ConfigMap?> CreateConfigMapAsync(
string configMapName,
IDictionary<string, string> 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<V1Secret?> CreateSecretAsync(
string secretName,
IDictionary<string, string> 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);
}
}
85 changes: 85 additions & 0 deletions tests/ES.Kubernetes.Reflector.Tests/ConfigMapMirrorTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
{ "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<string, string>
{
{ "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");
}));
}
}
69 changes: 69 additions & 0 deletions tests/ES.Kubernetes.Reflector.Tests/CustomWebApplicationFactory.cs
Original file line number Diff line number Diff line change
@@ -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<Program>, 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<IOptions<ReflectorOptions>>();

// 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<IKubernetes>(s =>
new k8s.Kubernetes(s.GetRequiredService<KubernetesClientConfiguration>()));
});
}

public Task InitializeAsync()
=> _container.StartAsync();

public new Task DisposeAsync()
=> _container.DisposeAsync().AsTask();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Polly.Core" Version="8.5.2" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testcontainers.K3s" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ES.Kubernetes.Reflector\ES.Kubernetes.Reflector.csproj" />
</ItemGroup>

</Project>
25 changes: 25 additions & 0 deletions tests/ES.Kubernetes.Reflector.Tests/HealthCheckTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading