diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000..3729ff0cd1
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
\ No newline at end of file
diff --git a/AspNetCore.Diagnostics.HealthChecks.sln b/AspNetCore.Diagnostics.HealthChecks.sln
index c116961240..f7901c6932 100644
--- a/AspNetCore.Diagnostics.HealthChecks.sln
+++ b/AspNetCore.Diagnostics.HealthChecks.sln
@@ -108,7 +108,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealthChecks.UI.Branding",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealthChecks.Azure.IoTHub", "src\HealthChecks.Azure.IoTHub\HealthChecks.Azure.IoTHub.csproj", "{252BB504-B7CB-4581-8CD8-D7398CAA16F5}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HealthChecks.Solr", "src\HealthChecks.Solr\HealthChecks.Solr.csproj", "{6054F41F-6FAA-4E7F-AAE3-5B22228C1468}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealthChecks.UI.K8s.Operator", "src\HealthChecks.UI.K8s.Operator\HealthChecks.UI.K8s.Operator.csproj", "{692313D3-E947-494A-83B7-754E2FFAF348}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealthChecks.UI.Image", "build\docker-images\HealthChecks.UI.Image\HealthChecks.UI.Image.csproj", "{737E4FD6-EA77-4608-A20F-767557FE3190}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker-image", "docker-image", "{95119F6F-87C8-45B8-8D95-61736FBEBEDE}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealthChecks.Solr", "src\HealthChecks.Solr\HealthChecks.Solr.csproj", "{6054F41F-6FAA-4E7F-AAE3-5B22228C1468}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -284,6 +290,14 @@ Global
{252BB504-B7CB-4581-8CD8-D7398CAA16F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{252BB504-B7CB-4581-8CD8-D7398CAA16F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{252BB504-B7CB-4581-8CD8-D7398CAA16F5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {692313D3-E947-494A-83B7-754E2FFAF348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {692313D3-E947-494A-83B7-754E2FFAF348}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {692313D3-E947-494A-83B7-754E2FFAF348}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {692313D3-E947-494A-83B7-754E2FFAF348}.Release|Any CPU.Build.0 = Release|Any CPU
+ {737E4FD6-EA77-4608-A20F-767557FE3190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {737E4FD6-EA77-4608-A20F-767557FE3190}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {737E4FD6-EA77-4608-A20F-767557FE3190}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {737E4FD6-EA77-4608-A20F-767557FE3190}.Release|Any CPU.Build.0 = Release|Any CPU
{6054F41F-6FAA-4E7F-AAE3-5B22228C1468}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6054F41F-6FAA-4E7F-AAE3-5B22228C1468}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6054F41F-6FAA-4E7F-AAE3-5B22228C1468}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -335,6 +349,8 @@ Global
{18F9E412-646D-4751-9751-30AA7A0233DF} = {2A3FD988-2BB8-43CF-B3A2-B70E648259D4}
{B526834E-9392-4749-BAB2-7DF579F8F418} = {092533AB-7505-4EDC-8932-D40BF575D0D2}
{252BB504-B7CB-4581-8CD8-D7398CAA16F5} = {2A3FD988-2BB8-43CF-B3A2-B70E648259D4}
+ {692313D3-E947-494A-83B7-754E2FFAF348} = {2A3FD988-2BB8-43CF-B3A2-B70E648259D4}
+ {737E4FD6-EA77-4608-A20F-767557FE3190} = {95119F6F-87C8-45B8-8D95-61736FBEBEDE}
{6054F41F-6FAA-4E7F-AAE3-5B22228C1468} = {2A3FD988-2BB8-43CF-B3A2-B70E648259D4}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
diff --git a/build-operator-image.ps1 b/build-operator-image.ps1
new file mode 100644
index 0000000000..3f61b297d8
--- /dev/null
+++ b/build-operator-image.ps1
@@ -0,0 +1,39 @@
+Param(
+ [parameter(Mandatory = $false)][bool]$PublishToDockerHub = $false
+)
+
+
+function Exec {
+ [CmdletBinding()]
+ param(
+ [Parameter(Position = 0, Mandatory = 1)][scriptblock]$cmd,
+ [Parameter(Position = 1, Mandatory = 0)][string]$errorMessage = ($msgs.error_bad_command -f $cmd)
+ )
+ & $cmd
+ if ($lastexitcode -ne 0) {
+ throw ("Exec: " + $errorMessage)
+ }
+}
+
+#Select the UI version from dependencies.props and use it as image version
+
+
+$version = select-xml -Path .\build\dependencies.props -XPath "/Project/PropertyGroup[contains(@Label,'Health Checks Package Versions')]/HealthChecksUIK8sOperator"
+
+$tag = $version.node.InnerXML
+
+#Building docker image
+
+echo "Building k8s operator docker image with tag: $tag"
+echo "Publishing to Docker Hub : $PublishToDockerHub"
+
+exec { & docker build . -f .\src\HealthChecks.UI.K8s.Operator\Dockerfile -t xabarilcoding/healthchecksui-k8s-operator:$tag }
+exec { & docker tag xabarilcoding/healthchecksui-k8s-operator:$tag xabarilcoding/healthchecksui-k8s-operator:latest }
+
+echo "Created docker image healthchecksui-k8s-operator:$tag. You can execute this image using docker run"
+
+#Publish it
+if ($PublishToDockerHub) {
+ docker push xabarilcoding/healthchecksui-k8s-operator:$tag
+ docker push xabarilcoding/healthchecksui-k8s-operator:latest
+}
\ No newline at end of file
diff --git a/build/dependencies.props b/build/dependencies.props
index bb08436f8c..758dabe7e6 100644
--- a/build/dependencies.props
+++ b/build/dependencies.props
@@ -41,6 +41,7 @@
3.0.0
1.0.0
1.0.0
+ 3.0.0
5.9.0
2.4.1
2.4.1
@@ -69,9 +70,9 @@
2.1.3
3.3.29
3.0.4
- 1.5.25
3.3.0
1.18.1
+ 1.6.10
1.0.19
@@ -117,5 +118,6 @@
3.0.0
3.0.0
3.0.0
+ 3.0.0-beta.1
diff --git a/build/docker-images/HealthChecks.UI.Image/Configuration/PushServiceKeys.cs b/build/docker-images/HealthChecks.UI.Image/Configuration/PushServiceKeys.cs
new file mode 100644
index 0000000000..19d940b4f6
--- /dev/null
+++ b/build/docker-images/HealthChecks.UI.Image/Configuration/PushServiceKeys.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.Image.Configuration
+{
+ public class PushServiceKeys
+ {
+ public const string Enabled = "enable_push_endpoint";
+ public const string PushEndpointSecret = "push_endpoint_secret";
+ public const int ServiceAdded = 0;
+ public const int ServiceUpdated = 1;
+ public const int ServiceRemoved = 2;
+ public const string AuthParameter = "key";
+ }
+}
diff --git a/build/docker-images/HealthChecks.UI.Image/Extensions/HttpRequestExtensions.cs b/build/docker-images/HealthChecks.UI.Image/Extensions/HttpRequestExtensions.cs
new file mode 100644
index 0000000000..fc720f73f9
--- /dev/null
+++ b/build/docker-images/HealthChecks.UI.Image/Extensions/HttpRequestExtensions.cs
@@ -0,0 +1,18 @@
+using HealthChecks.UI.Image.Configuration;
+using Microsoft.AspNetCore.Http;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.Image.Extensions
+{
+ public static class HttpRequestExtensions
+ {
+ public static bool IsAuthenticated(this HttpRequest request)
+ {
+ return request.Query.ContainsKey(PushServiceKeys.AuthParameter) &&
+ request.Query[PushServiceKeys.AuthParameter] == Environment.GetEnvironmentVariable(PushServiceKeys.PushEndpointSecret);
+ }
+ }
+}
diff --git a/build/docker-images/HealthChecks.UI.Image/Extensions/IEndpointRouteBuilderExtensions.cs b/build/docker-images/HealthChecks.UI.Image/Extensions/IEndpointRouteBuilderExtensions.cs
index f596371603..299b4990f6 100644
--- a/build/docker-images/HealthChecks.UI.Image/Extensions/IEndpointRouteBuilderExtensions.cs
+++ b/build/docker-images/HealthChecks.UI.Image/Extensions/IEndpointRouteBuilderExtensions.cs
@@ -1,24 +1,78 @@
-using HealthChecks.UI.Image.Configuration;
+using HealthChecks.UI.Image;
+using HealthChecks.UI.Image.Configuration;
using HealthChecks.UI.Image.Extensions;
-using Microsoft.AspNetCore.Builder;
+using HealthChecks.UI.Image.PushService;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
+using System.IO;
+using System.Text.Json;
namespace Microsoft.AspNetCore.Builder
{
public static class IEndpointRouteBuilderExtensions
{
- public static IEndpointConventionBuilder MapHealthChecksUI(this IEndpointRouteBuilder builder, IConfiguration configuration)
- {
+ public static IEndpointConventionBuilder MapHealthChecksUI(this IEndpointRouteBuilder builder,
+ IConfiguration configuration)
+ {
+ if (bool.TryParse(configuration[PushServiceKeys.Enabled], out bool enabled) && enabled)
+ {
+ builder.MapHealthCheckPushEndpoint(configuration);
+ }
+
return builder.MapHealthChecksUI(setup =>
{
setup.ConfigureStylesheet(configuration);
setup.ConfigurePaths(configuration);
+
+ });
+ }
+ private static void MapHealthCheckPushEndpoint(this IEndpointRouteBuilder builder,
+ IConfiguration configuration)
+ {
+
+ var logger = builder.ServiceProvider.GetRequiredService>();
+ logger.LogInformation("HealthChecks Push Endpoint Enabled");
+
+ builder.MapPost("/healthchecks/push", async context =>
+ {
+ if (context.Request.IsAuthenticated())
+ {
+ using var streamReader = new StreamReader(context.Request.Body);
+ var content = await streamReader.ReadToEndAsync();
+
+ var endpoint = JsonDocument.Parse(content);
+ var root = endpoint.RootElement;
+ var name = root.GetProperty("name").GetString();
+ var uri = root.GetProperty("uri").GetString();
+ var type = root.GetProperty("type").GetInt16();
+
+ if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(uri))
+ {
+ var pushService = context.RequestServices.GetService();
+
+ if (type == PushServiceKeys.ServiceAdded)
+ {
+ await pushService.AddAsync(name, uri);
+ }
+ else if (type == PushServiceKeys.ServiceRemoved)
+ {
+ await pushService.RemoveAsync(name);
+ }
+ else if (type == PushServiceKeys.ServiceUpdated)
+ {
+ await pushService.UpdateAsync(name, uri);
+ }
+ }
+ }
+ else
+ {
+ context.Response.StatusCode = 401;
+ }
+
});
}
}
-}
+}
\ No newline at end of file
diff --git a/build/docker-images/HealthChecks.UI.Image/HealthChecks.UI.Image.csproj b/build/docker-images/HealthChecks.UI.Image/HealthChecks.UI.Image.csproj
index ffb39d20f3..e12d6c1782 100644
--- a/build/docker-images/HealthChecks.UI.Image/HealthChecks.UI.Image.csproj
+++ b/build/docker-images/HealthChecks.UI.Image/HealthChecks.UI.Image.csproj
@@ -5,6 +5,7 @@
+
diff --git a/build/docker-images/HealthChecks.UI.Image/Program.cs b/build/docker-images/HealthChecks.UI.Image/Program.cs
index e02a20dc35..db58e6e20f 100644
--- a/build/docker-images/HealthChecks.UI.Image/Program.cs
+++ b/build/docker-images/HealthChecks.UI.Image/Program.cs
@@ -22,7 +22,11 @@ public static void Main(string[] args)
public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
- return WebHost.CreateDefaultBuilder(args)
+ return WebHost.CreateDefaultBuilder(args)
+ .ConfigureLogging(config =>
+ {
+ config.AddFilter(typeof(Program).Namespace, LogLevel.Information);
+ })
.ConfigureAppConfiguration(config =>
{
if (AzureAppConfiguration.Enabled)
diff --git a/build/docker-images/HealthChecks.UI.Image/PushService/HealthChecksPushService.cs b/build/docker-images/HealthChecks.UI.Image/PushService/HealthChecksPushService.cs
new file mode 100644
index 0000000000..c0f6afbfaf
--- /dev/null
+++ b/build/docker-images/HealthChecks.UI.Image/PushService/HealthChecksPushService.cs
@@ -0,0 +1,70 @@
+using HealthChecks.UI.Core.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.Image.PushService
+{
+ internal class HealthChecksPushService
+ {
+ private readonly HealthChecksDb _db;
+ private readonly ILogger _logger;
+
+ public HealthChecksPushService(HealthChecksDb db, ILogger logger)
+ {
+ _db = db ?? throw new ArgumentNullException(nameof(db));
+ _logger = logger ?? throw new ArgumentException(nameof(logger));
+ }
+ public async Task AddAsync(string name, string uri)
+ {
+ if ((await Get(name)) == null)
+ {
+ await _db.Configurations.AddAsync(new HealthCheckConfiguration
+ {
+ Name = name,
+ Uri = uri,
+ DiscoveryService = "kubernetes"
+ });
+
+ await _db.SaveChangesAsync();
+
+ _logger.LogInformation("[Push] New service added: {name} with uri: {uri}", name, uri);
+ }
+ }
+
+ public async Task RemoveAsync(string name)
+ {
+ var endpoint = await Get(name);
+
+ if (endpoint != null)
+ {
+ _db.Configurations.Remove(endpoint);
+ await _db.SaveChangesAsync();
+
+ _logger.LogInformation("[Push] Service removed: {name}", name);
+ }
+ }
+
+ public async Task UpdateAsync(string name, string uri)
+ {
+ var endpoint = await Get(name);
+
+ if (endpoint != null)
+ {
+ endpoint.Uri = uri;
+ _db.Configurations.Update(endpoint);
+ await _db.SaveChangesAsync();
+
+ _logger.LogInformation("[Push] Service updated: {name} with uri {uri}", name, uri);
+ }
+ }
+
+ private Task Get(string name)
+ {
+ return _db.Configurations.FirstOrDefaultAsync(c => c.Name == name);
+ }
+ }
+}
diff --git a/build/docker-images/HealthChecks.UI.Image/Startup.cs b/build/docker-images/HealthChecks.UI.Image/Startup.cs
index 833b83c559..b55c2e077b 100644
--- a/build/docker-images/HealthChecks.UI.Image/Startup.cs
+++ b/build/docker-images/HealthChecks.UI.Image/Startup.cs
@@ -1,9 +1,11 @@
using HealthChecks.UI.Image.Configuration;
+using HealthChecks.UI.Image.PushService;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-
+using System;
+
namespace HealthChecks.UI.Image
{
public class Startup
@@ -17,12 +19,20 @@ public Startup(IConfiguration configuration)
public void ConfigureServices(IServiceCollection services)
{
- services
+ services
.AddHealthChecksUI()
.AddMvc()
- .SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
+ .SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
+
+ if (bool.TryParse(Configuration[PushServiceKeys.Enabled], out bool enabled) && enabled)
+ {
+ if(string.IsNullOrEmpty(Configuration[PushServiceKeys.PushEndpointSecret]))
+ {
+ throw new Exception($"{PushServiceKeys.PushEndpointSecret} environment variable has not been configured");
+ }
+ services.AddTransient();
+ }
}
-
public void Configure(IApplicationBuilder app)
{
app.UseRouting()
diff --git a/deploy/operator/crd/healthcheck-crd.yaml b/deploy/operator/crd/healthcheck-crd.yaml
new file mode 100644
index 0000000000..78dd58ea5e
--- /dev/null
+++ b/deploy/operator/crd/healthcheck-crd.yaml
@@ -0,0 +1,48 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+ name: healthchecks.aspnetcore.ui
+spec:
+ group: aspnetcore.ui
+ names:
+ plural: healthchecks
+ singular: healthcheck
+ kind: HealthCheck
+ listKind: HealthChecks
+ shortNames:
+ - hc
+ versions:
+ - name: v1
+ served: true
+ storage: true
+ scope: Namespaced
+ validation:
+ openAPIV3Schema:
+ properties:
+ spec:
+ properties:
+ name:
+ type: string
+ serviceType:
+ type: string
+ enum:
+ - ClusterIP
+ - LoadBalancer
+ - NodePort
+ portNumber:
+ type: number
+ uiPath:
+ type: string
+ servicesLabel:
+ type: string
+ healthChecksPath:
+ type: string
+ healthChecksScheme:
+ type: string
+ image:
+ type: string
+ imagePullPolicy:
+ type: string
+ required:
+ - name
+ - servicesLabel
diff --git a/deploy/operator/deployment.yaml b/deploy/operator/deployment.yaml
new file mode 100644
index 0000000000..3e8312eb78
--- /dev/null
+++ b/deploy/operator/deployment.yaml
@@ -0,0 +1,15 @@
+apiVersion: extensions/v1beta1
+kind: Deployment
+metadata:
+ name: healthchecks-ui-k8s-operator-deploy
+spec:
+ replicas: 1
+ template:
+ metadata:
+ labels:
+ app: healthchecks-ui-k8s-operator
+ spec:
+ containers:
+ - name: healthchecks-ui-k8s-operator
+ image: healthchecksui-k8s-operator
+ imagePullPolicy: Always
diff --git a/src/HealthChecks.UI.K8s.Operator/Constants.cs b/src/HealthChecks.UI.K8s.Operator/Constants.cs
new file mode 100644
index 0000000000..13e31dea87
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Constants.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ internal class Constants
+ {
+ //Kubernetes 1.17 allows Defaulting values - Next operators versions might use CRD default values instead of constant defaults
+ public const string Group = "aspnetcore.ui";
+ public const string Version = "v1";
+ public const string Plural = "healthchecks";
+ public const string PodName = "healthchecks-ui";
+ public const string ImageName = "xabarilcoding/healthchecksui";
+ public const string PushServicePath = "/healthchecks/push";
+ public const string HealthCheckPathAnnotation = "HealthChecksPath";
+ public const string HealthCheckSchemeAnnotation = "HealthChecksScheme";
+ public const string DefaultPullPolicy = "Always";
+ public const string DefaultServiceType = "ClusterIP";
+ public const string DefaultUIPath = "/healthchecks";
+ public const string DefaultPort = "80";
+ public const string DefaultScheme = "http";
+ public const string DefaultHealthPath = "health";
+ public const string PushServiceAuthKey = "key";
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Controller/DeploymentResult.cs b/src/HealthChecks.UI.K8s.Operator/Controller/DeploymentResult.cs
new file mode 100644
index 0000000000..4f404fa3d8
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Controller/DeploymentResult.cs
@@ -0,0 +1,23 @@
+using System;
+using k8s.Models;
+
+namespace HealthChecks.UI.K8s.Operator.Controller
+{
+ public class DeploymentResult
+ {
+ public V1Deployment Deployment { get; private set; }
+ public V1Service Service { get; private set; }
+ public V1Secret Secret { get; set; }
+
+ private DeploymentResult(V1Deployment deployment, V1Service service, V1Secret secret)
+ {
+ Deployment = deployment ?? throw new ArgumentNullException(nameof(deployment));
+ Service = service ?? throw new ArgumentNullException(nameof(service));
+ Secret = secret ?? throw new ArgumentNullException(nameof(secret));
+ }
+ public static DeploymentResult Create(V1Deployment deployment, V1Service service, V1Secret secret)
+ {
+ return new DeploymentResult(deployment, service, secret);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Controller/HealthChecksController.cs b/src/HealthChecks.UI.K8s.Operator/Controller/HealthChecksController.cs
new file mode 100644
index 0000000000..b7a7164003
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Controller/HealthChecksController.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Threading.Tasks;
+using HealthChecks.UI.K8s.Operator.Handlers;
+using k8s;
+using Microsoft.Extensions.Logging;
+
+namespace HealthChecks.UI.K8s.Operator.Controller
+{
+ class HealthChecksController : IHealthChecksController
+ {
+ private readonly IKubernetes _client;
+ private readonly DeploymentHandler _deploymentHandler;
+ private readonly ServiceHandler _serviceHandler;
+ private readonly SecretHandler _secretHandler;
+ private readonly ILogger _logger;
+
+ public HealthChecksController(
+ IKubernetes client,
+ DeploymentHandler deploymentHandler,
+ ServiceHandler serviceHandler,
+ SecretHandler secretHandler,
+ ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _deploymentHandler = deploymentHandler ?? throw new ArgumentNullException(nameof(deploymentHandler));
+ _serviceHandler = serviceHandler ?? throw new ArgumentNullException(nameof(serviceHandler));
+ _secretHandler = secretHandler ?? throw new ArgumentNullException(nameof(secretHandler));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task DeployAsync(HealthCheckResource resource)
+ {
+ _logger.LogInformation("Creating secret for hc resource - namespace {namespace}", resource.Metadata.NamespaceProperty);
+
+ var secret = await _secretHandler.GetOrCreate(resource);
+
+ _logger.LogInformation("Creating deployment for hc resource - namespace {namespace}", resource.Metadata.NamespaceProperty);
+
+ var deployment = await _deploymentHandler.GetOrCreateAsync(resource);
+
+ _logger.LogInformation("Creating service for hc resource - namespace {namespace}", resource.Metadata.NamespaceProperty);
+
+ var service = await _serviceHandler.GetOrCreateAsync(resource);
+
+ return DeploymentResult.Create(deployment, service, secret);
+ }
+
+ public async Task DeleteDeploymentAsync(HealthCheckResource resource)
+ {
+ _logger.LogInformation("Deleting healthchecks deployment {name}", resource.Spec.Name);
+
+ await _secretHandler.Delete(resource);
+ await _deploymentHandler.Delete(resource);
+ await _serviceHandler.Delete(resource);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Controller/IHealthChecksController.cs b/src/HealthChecks.UI.K8s.Operator/Controller/IHealthChecksController.cs
new file mode 100644
index 0000000000..7422eb1be4
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Controller/IHealthChecksController.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator.Controller
+{
+ internal interface IHealthChecksController
+ {
+ Task DeployAsync(HealthCheckResource resource);
+ Task DeleteDeploymentAsync(HealthCheckResource resource);
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Crd/CustomResource.cs b/src/HealthChecks.UI.K8s.Operator/Crd/CustomResource.cs
new file mode 100644
index 0000000000..c67d154bdb
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Crd/CustomResource.cs
@@ -0,0 +1,15 @@
+using k8s;
+using k8s.Models;
+
+namespace HealthChecks.UI.K8s.Operator.Crd
+{
+ public abstract class CustomResource : CustomResource
+ {
+ public TSpec Spec { get; set; }
+ public TStatus Status { get; set; }
+ }
+ public abstract class CustomResource : KubernetesObject
+ {
+ public V1ObjectMeta Metadata { get; set; }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Crd/CustomResourceList.cs b/src/HealthChecks.UI.K8s.Operator/Crd/CustomResourceList.cs
new file mode 100644
index 0000000000..2f26bbd556
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Crd/CustomResourceList.cs
@@ -0,0 +1,12 @@
+using k8s;
+using k8s.Models;
+using System.Collections.Generic;
+
+namespace HealthChecks.UI.K8s.Operator.Crd
+{
+ public abstract class CustomResourceList : KubernetesObject where T : CustomResource
+ {
+ public V1ListMeta Metadata { get; set; }
+ public List Items { get; set; }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResource.cs b/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResource.cs
new file mode 100644
index 0000000000..ba13b5ce14
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResource.cs
@@ -0,0 +1,8 @@
+using System;
+using HealthChecks.UI.K8s.Operator.Crd;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ public class HealthCheckResource : CustomResource { }
+
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResourceList.cs b/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResourceList.cs
new file mode 100644
index 0000000000..401283f89e
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResourceList.cs
@@ -0,0 +1,6 @@
+using HealthChecks.UI.K8s.Operator.Crd;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ public class HealthCheckResourceList : CustomResourceList { }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResourceSpec.cs b/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResourceSpec.cs
new file mode 100644
index 0000000000..3a507b1b93
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Crd/HealthCheckResourceSpec.cs
@@ -0,0 +1,15 @@
+namespace HealthChecks.UI.K8s.Operator
+{
+ public class HealthCheckResourceSpec
+ {
+ public string Name { get; set; }
+ public string PortNumber { get; set; }
+ public string ServiceType { get; set; }
+ public string UiPath { get; set; }
+ public string ServicesLabel { get; set; }
+ public string HealthChecksPath { get; set; }
+ public string HealthChecksScheme { get; set; }
+ public string Image { get; set; }
+ public string ImagePullPolicy { get; set; }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Dockerfile b/src/HealthChecks.UI.K8s.Operator/Dockerfile
new file mode 100644
index 0000000000..76a6327b7b
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Dockerfile
@@ -0,0 +1,20 @@
+#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.
+
+FROM mcr.microsoft.com/dotnet/core/runtime:3.0-buster-slim AS base
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
+WORKDIR /src
+COPY ["src/HealthChecks.UI.K8s.Operator/HealthChecks.UI.K8s.Operator.csproj", "src/HealthChecks.UI.K8s.Operator/"]
+RUN dotnet restore "src/HealthChecks.UI.K8s.Operator/HealthChecks.UI.K8s.Operator.csproj"
+COPY . .
+WORKDIR "/src/src/HealthChecks.UI.K8s.Operator"
+RUN dotnet build "HealthChecks.UI.K8s.Operator.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "HealthChecks.UI.K8s.Operator.csproj" -c Release -o /app/publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "HealthChecks.UI.K8s.Operator.dll"]
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Extensions/AsyncExtensions.cs b/src/HealthChecks.UI.K8s.Operator/Extensions/AsyncExtensions.cs
new file mode 100644
index 0000000000..a151845295
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Extensions/AsyncExtensions.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ public static class AsyncExtensions
+ {
+ public static Task WaitAsync(this CancellationToken token)
+ {
+ var tsc = new TaskCompletionSource();
+ token.Register(CancellationTokenCallback, tsc);
+
+ return token.IsCancellationRequested ? Task.CompletedTask : tsc.Task;
+ }
+
+ public static void CancellationTokenCallback(object taskCompletionSource)
+ {
+ ((TaskCompletionSource)taskCompletionSource).SetResult(true);
+ }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Extensions/HealthCheckResourceExtensions.cs b/src/HealthChecks.UI.K8s.Operator/Extensions/HealthCheckResourceExtensions.cs
new file mode 100644
index 0000000000..285ec00560
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Extensions/HealthCheckResourceExtensions.cs
@@ -0,0 +1,19 @@
+using k8s.Models;
+
+namespace HealthChecks.UI.K8s.Operator.Extensions
+{
+ public static class HealthCheckResourceExtensions
+ {
+ public static V1OwnerReference CreateOwnerReference(this HealthCheckResource resource)
+ {
+ return new V1OwnerReference
+ {
+ Name = resource.Spec.Name,
+ ApiVersion = resource.ApiVersion,
+ Uid = resource.Metadata.Uid,
+ Kind = resource.Kind,
+ Controller = true
+ };
+ }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Extensions/IKubernetesExtensions.cs b/src/HealthChecks.UI.K8s.Operator/Extensions/IKubernetesExtensions.cs
new file mode 100644
index 0000000000..2d1e0d36a8
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Extensions/IKubernetesExtensions.cs
@@ -0,0 +1,27 @@
+using k8s.Models;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace k8s
+{
+ public static class IKubernetesExtensions
+ {
+ public static async Task ListNamespacedOwnedDeploymentAsync(this IKubernetes client, string k8sNamespace, string ownerUniqueId)
+ {
+ var services = await client.ListNamespacedDeploymentAsync(k8sNamespace);
+ return services.Items.FirstOrDefault(i => i.Metadata.OwnerReferences?.Any(or => or.Uid == ownerUniqueId) ?? false);
+ }
+
+ public static async Task ListNamespacedOwnedServiceAsync(this IKubernetes client, string k8sNamespace, string ownerUniqueId)
+ {
+ var services = await client.ListNamespacedServiceAsync(k8sNamespace);
+ return services.Items.FirstOrDefault(i => i.Metadata.OwnerReferences?.Any(or => or.Uid == ownerUniqueId) ?? false);
+ }
+
+ public static async Task ListNamespacedOwnedSecretAsync(this IKubernetes client, string k8sNamespace, string ownerUniqueId)
+ {
+ var services = await client.ListNamespacedSecretAsync(k8sNamespace);
+ return services.Items.FirstOrDefault(i => i.Metadata.OwnerReferences?.Any(or => or.Uid == ownerUniqueId) ?? false);
+ }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Handlers/DeploymentHandler.cs b/src/HealthChecks.UI.K8s.Operator/Handlers/DeploymentHandler.cs
new file mode 100644
index 0000000000..16fcad6028
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Handlers/DeploymentHandler.cs
@@ -0,0 +1,127 @@
+using HealthChecks.UI.K8s.Operator.Extensions;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator.Handlers
+{
+ public class DeploymentHandler
+ {
+ private readonly IKubernetes _client;
+ private readonly ILogger _logger;
+
+ public DeploymentHandler(IKubernetes client, ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Task Get(HealthCheckResource resource)
+ {
+ return _client.ListNamespacedOwnedDeploymentAsync(resource.Metadata.NamespaceProperty, resource.Metadata.Uid);
+ }
+
+ public async Task GetOrCreateAsync(HealthCheckResource resource)
+ {
+ var deployment = await Get(resource);
+ if (deployment != null) return deployment;
+
+ try
+ {
+ var deploymentResource = Build(resource);
+ var response =
+ await _client.CreateNamespacedDeploymentWithHttpMessagesAsync(deploymentResource,
+ resource.Metadata.NamespaceProperty);
+ deployment = response.Body;
+
+ _logger.LogInformation("Deployment {deployment} has been created", deployment.Metadata.Name);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Error creating deployment: {error}", ex.Message);
+ }
+
+ return deployment;
+
+ }
+
+ public async Task Delete(HealthCheckResource resource)
+ {
+ try
+ {
+ await _client.DeleteNamespacedDeploymentAsync($"{resource.Spec.Name}-deploy",
+ resource.Metadata.NamespaceProperty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Error deleting deployment for hc resource: {name} - err: {error}", resource.Spec.Name, ex.Message);
+ }
+ }
+
+ public V1Deployment Build(HealthCheckResource resource)
+ {
+
+ var metadata = new V1ObjectMeta
+ {
+ OwnerReferences = new List {
+ resource.CreateOwnerReference()
+ },
+ Labels = new Dictionary
+ {
+ ["app"] = resource.Spec.Name
+ },
+ Name = $"{resource.Spec.Name}-deploy",
+ NamespaceProperty = resource.Metadata.NamespaceProperty
+ };
+
+ var spec = new V1DeploymentSpec
+ {
+ Selector = new V1LabelSelector
+ {
+ MatchLabels = new Dictionary
+ {
+ ["app"] = resource.Spec.Name
+ }
+ },
+ Replicas = 1,
+ Template = new V1PodTemplateSpec
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Labels = new Dictionary
+ {
+ ["app"] = resource.Spec.Name
+ }
+ },
+ Spec = new V1PodSpec
+ {
+ Containers = new List
+ {
+ new V1Container
+ {
+ ImagePullPolicy = resource.Spec.ImagePullPolicy ?? Constants.DefaultPullPolicy,
+ Name = Constants.PodName,
+ Image = resource.Spec.Image ?? Constants.ImageName,
+ Ports = new List
+ {
+ new V1ContainerPort(80)
+ },
+ Env = new List
+ {
+ new V1EnvVar("ui_path", resource.Spec.UiPath ?? Constants.DefaultUIPath),
+ new V1EnvVar("enable_push_endpoint", "true"),
+ new V1EnvVar("push_endpoint_secret", valueFrom: new V1EnvVarSource(secretKeyRef: new V1SecretKeySelector("key", $"{resource.Spec.Name}-secret")))
+ }
+ }
+ }
+ }
+ }
+ };
+
+ return new V1Deployment(metadata: metadata, spec: spec);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Handlers/SecretHandler.cs b/src/HealthChecks.UI.K8s.Operator/Handlers/SecretHandler.cs
new file mode 100644
index 0000000000..ada4c57dac
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Handlers/SecretHandler.cs
@@ -0,0 +1,86 @@
+using HealthChecks.UI.K8s.Operator.Extensions;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator.Handlers
+{
+ public class SecretHandler
+ {
+ private readonly IKubernetes _client;
+ private readonly ILogger _logger;
+
+ public SecretHandler(IKubernetes client, ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client)); ;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async Task GetOrCreate(HealthCheckResource resource)
+ {
+ var secret = await Get(resource);
+ if (secret != null) return secret;
+
+ try
+ {
+ var secretResource = Build(resource);
+ secret = await _client.CreateNamespacedSecretAsync(secretResource,
+ resource.Metadata.NamespaceProperty);
+
+ _logger.LogInformation("Secret {name} has been created", secret.Metadata.Name);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Error creating Secret: {message}", ex.Message);
+ }
+
+ return secret;
+ }
+
+ public Task Get(HealthCheckResource resource)
+ {
+ return _client.ListNamespacedOwnedSecretAsync(resource.Metadata.NamespaceProperty, resource.Metadata.Uid);
+ }
+
+ public async Task Delete(HealthCheckResource resource)
+ {
+ try
+ {
+ await _client.DeleteNamespacedSecretAsync($"{resource.Spec.Name}-secret", resource.Metadata.NamespaceProperty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Error deleting secret for hc resource {name} : {message}", resource.Spec.Name, ex.Message);
+ }
+ }
+
+ public V1Secret Build(HealthCheckResource resource)
+ {
+ return new V1Secret
+ {
+ Metadata = new V1ObjectMeta
+ {
+ Name = $"{resource.Spec.Name}-secret",
+ NamespaceProperty = resource.Metadata.NamespaceProperty,
+ OwnerReferences = new List {
+ resource.CreateOwnerReference()
+ },
+ Labels = new Dictionary
+ {
+ ["app"] = resource.Spec.Name
+ }
+ },
+ Data = new Dictionary
+ {
+ ["key"] = Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())
+ }
+ };
+ }
+
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Handlers/ServiceHandler.cs b/src/HealthChecks.UI.K8s.Operator/Handlers/ServiceHandler.cs
new file mode 100644
index 0000000000..129fca6597
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Handlers/ServiceHandler.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using HealthChecks.UI.K8s.Operator.Extensions;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace HealthChecks.UI.K8s.Operator.Handlers
+{
+ public class ServiceHandler
+ {
+ private readonly IKubernetes _client;
+ private readonly ILogger _logger;
+
+ public ServiceHandler(IKubernetes client, ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Task Get(HealthCheckResource resource)
+ {
+ return _client.ListNamespacedOwnedServiceAsync(resource.Metadata.NamespaceProperty, resource.Metadata.Uid);
+ }
+
+ public async Task GetOrCreateAsync(HealthCheckResource resource)
+ {
+ var service = await Get(resource);
+ if (service != null) return service;
+
+ try
+ {
+ var serviceResource = Build(resource);
+ service = await _client.CreateNamespacedServiceAsync(serviceResource, resource.Metadata.NamespaceProperty);
+ _logger.LogInformation("Service {name} has been created", service.Metadata.Name);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Error creating service for hc resource {name} : {message}", resource.Spec.Name, ex.Message);
+ }
+
+ return service;
+ }
+
+ public async Task Delete(HealthCheckResource resource)
+ {
+ try
+ {
+ await _client.DeleteNamespacedServiceAsync($"{resource.Spec.Name}-svc", resource.Metadata.NamespaceProperty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Error deleting service for hc resource {name} : {message}", resource.Spec.Name, ex.Message);
+ }
+ }
+ public V1Service Build(HealthCheckResource resource)
+ {
+ var meta = new V1ObjectMeta
+ {
+ Name = $"{resource.Spec.Name}-svc",
+ OwnerReferences = new List {
+ resource.CreateOwnerReference()
+ },
+ Labels = new Dictionary
+ {
+ ["app"] = resource.Spec.Name
+ },
+ };
+
+ var spec = new V1ServiceSpec
+ {
+ Selector = new Dictionary
+ {
+ ["app"] = resource.Spec.Name
+ },
+ Type = resource.Spec.ServiceType ?? Constants.DefaultServiceType,
+ Ports = new List {
+ new V1ServicePort {
+ Name = "httport",
+ Port = int.Parse(resource.Spec.PortNumber ?? Constants.DefaultPort),
+ TargetPort = 80
+ }
+ }
+ };
+
+ return new V1Service(metadata: meta, spec: spec);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/HealthChecks.UI.K8s.Operator.csproj b/src/HealthChecks.UI.K8s.Operator/HealthChecks.UI.K8s.Operator.csproj
new file mode 100644
index 0000000000..279b0acc9e
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/HealthChecks.UI.K8s.Operator.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ netcoreapp3.0
+ Linux
+ ..\..
+ $(HealthChecksUIK8sOperator)
+
+
+
+
+
+
+
+
+
diff --git a/src/HealthChecks.UI.K8s.Operator/Operator/HealthCheckServiceWatcher.cs b/src/HealthChecks.UI.K8s.Operator/Operator/HealthCheckServiceWatcher.cs
new file mode 100644
index 0000000000..50fbbc23c0
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Operator/HealthCheckServiceWatcher.cs
@@ -0,0 +1,89 @@
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ internal class HealthCheckServiceWatcher : IDisposable
+ {
+ private readonly IKubernetes _client;
+ private readonly ILogger _logger;
+ private Watcher _watcher;
+ private Dictionary> _watchers = new Dictionary>();
+
+ public HealthCheckServiceWatcher(IKubernetes client, ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ internal Watcher Watch(HealthCheckResource resource, CancellationToken token)
+ {
+ Func filter = (k) => k.Metadata.NamespaceProperty == resource.Metadata.NamespaceProperty;
+
+ if (!_watchers.Keys.Any(filter))
+ {
+ var response = _client.ListNamespacedServiceWithHttpMessagesAsync(
+ namespaceParameter: resource.Metadata.NamespaceProperty,
+ labelSelector: $"{resource.Spec.ServicesLabel}",
+ watch: true,
+ cancellationToken: token);
+
+ _watcher = response.Watch(
+ onEvent: async (type, item) => await OnServiceDiscoveredAsync(type, item, resource),
+ onError: e => _logger.LogError(e, "Error in service watcher: {message}", e.Message)
+ );
+
+ _watchers.Add(resource, _watcher);
+
+ return _watcher;
+ }
+
+ return null;
+ }
+
+ internal void Stopwatch(HealthCheckResource resource)
+ {
+ Func filter = (k) => k.Metadata.NamespaceProperty == resource.Metadata.NamespaceProperty;
+ if (_watchers.Keys.Any(filter))
+ {
+ var svcResource = _watchers.Keys.FirstOrDefault(filter);
+ if (svcResource != null)
+ {
+ _logger.LogInformation("Stopping services watcher for namespace {namespace}", resource.Metadata.NamespaceProperty);
+ _watchers[svcResource].Dispose();
+ _watchers.Remove(svcResource);
+ }
+ }
+ }
+
+ internal async Task OnServiceDiscoveredAsync(WatchEventType type, V1Service service, HealthCheckResource resource)
+ {
+ var uiService = await _client.ListNamespacedOwnedServiceAsync(resource.Metadata.NamespaceProperty, resource.Metadata.Uid);
+ var secret = await _client.ListNamespacedOwnedSecretAsync(resource.Metadata.NamespaceProperty, resource.Metadata.Uid);
+
+ if (!service.Metadata.Labels.ContainsKey(resource.Spec.ServicesLabel))
+ {
+ type = WatchEventType.Deleted;
+ }
+
+ await HealthChecksPushService.PushNotification(
+ type,
+ resource,
+ uiService,
+ service,
+ secret,
+ _logger);
+ }
+
+ public void Dispose()
+ {
+ _watchers.Values.ToList().ForEach(w => w?.Dispose());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Operator/HealthChecksOperator.cs b/src/HealthChecks.UI.K8s.Operator/Operator/HealthChecksOperator.cs
new file mode 100644
index 0000000000..ee0e5248a5
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Operator/HealthChecksOperator.cs
@@ -0,0 +1,80 @@
+using HealthChecks.UI.K8s.Operator.Controller;
+using k8s;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ internal class HealthChecksOperator : IKubernetesOperator
+ {
+ private Watcher _watcher;
+ private readonly IKubernetes _client;
+ private readonly IHealthChecksController _controller;
+ private readonly HealthCheckServiceWatcher _serviceWatcher;
+ private readonly ILogger _logger;
+
+ public HealthChecksOperator(
+ IKubernetes client,
+ IHealthChecksController controller,
+ HealthCheckServiceWatcher serviceWatcher,
+ ILogger logger)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ _controller = controller ?? throw new ArgumentNullException(nameof(controller));
+ _serviceWatcher = serviceWatcher;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ private async Task StartWatcher(CancellationToken token)
+ {
+ var response = await _client.ListClusterCustomObjectWithHttpMessagesAsync(
+ group: Constants.Group,
+ version: Constants.Version,
+ plural: Constants.Plural,
+ watch: true,
+ timeoutSeconds: ((int)TimeSpan.FromMinutes(60).TotalSeconds),
+ cancellationToken: token
+ );
+
+ _watcher = response.Watch(
+ onEvent: async (type, item) => await OnEventHandlerAsync(type, item, token)
+ ,
+ onClosed: () =>
+ {
+ _watcher.Dispose();
+ _ = StartWatcher(token);
+ },
+ onError: e => _logger.LogError(e.Message)
+ );
+ }
+
+
+ public async Task RunAsync(CancellationToken token)
+ {
+ await StartWatcher(token);
+ }
+
+ private async Task OnEventHandlerAsync(WatchEventType type, HealthCheckResource item, CancellationToken token)
+ {
+ if (type == WatchEventType.Added)
+ {
+ await _controller.DeployAsync(item);
+ _serviceWatcher.Watch(item, token);
+ }
+
+ if (type == WatchEventType.Deleted)
+ {
+ await _controller.DeleteDeploymentAsync(item);
+ _serviceWatcher.Stopwatch(item);
+ }
+ }
+
+ public void Dispose()
+ {
+ _serviceWatcher?.Dispose();
+ _watcher?.Dispose();
+ }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Operator/HealthChecksPushService.cs b/src/HealthChecks.UI.K8s.Operator/Operator/HealthChecksPushService.cs
new file mode 100644
index 0000000000..fa4cdcddd3
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Operator/HealthChecksPushService.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using HealthChecks.UI.K8s.Operator.Operator;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ public class HealthChecksPushService
+ {
+ public static async Task PushNotification(
+ WatchEventType eventType,
+ HealthCheckResource resource,
+ V1Service uiService,
+ V1Service notificationService,
+ V1Secret endpointSecret,
+ ILogger logger)
+ {
+ var address = KubernetesAddressFactory.CreateHealthAddress(notificationService, resource);
+ var uiAddress = KubernetesAddressFactory.CreateAddress(uiService, resource);
+
+ dynamic healthCheck = new
+ {
+ Type = eventType,
+ notificationService.Metadata.Name,
+ Uri = address
+ };
+
+ using var client = new HttpClient();
+ try
+ {
+ string type = healthCheck.Type.ToString();
+ string name = healthCheck.Name;
+ string uri = healthCheck.Uri;
+
+ logger.LogInformation("[PushService] Sending Type: {type} - Service {name} with uri : {uri} to ui endpoint: {address}", type, name, uri, uiAddress);
+
+ var key = Encoding.UTF8.GetString(endpointSecret.Data["key"]);
+
+ var response = await client.PostAsync($"{uiAddress}{Constants.PushServicePath}?{Constants.PushServiceAuthKey}={key}",
+
+ new StringContent(JsonSerializer.Serialize(healthCheck, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ }), Encoding.UTF8, "application/json"));
+
+
+ logger.LogInformation("[PushService] Notification result for {name} - status code: {statuscode}", notificationService.Metadata.Name, response.StatusCode);
+
+ }
+ catch (Exception ex)
+ {
+ logger.LogError("Error notifying healthcheck service: {message}", ex.Message);
+ }
+ }
+
+ private static (string address, V1ServicePort port) GetServiceAddress(V1Service service)
+ {
+
+ string IpAddress = default;
+
+ if (service.Spec.Type == ServiceType.LoadBalancer)
+ {
+ var ingress = service.Status?.LoadBalancer?.Ingress?.FirstOrDefault();
+ if (ingress != null)
+ {
+ IpAddress = ingress.Ip ?? ingress.Hostname;
+ }
+ else
+ {
+ IpAddress = service.Spec.ClusterIP;
+ }
+ }
+ else
+ {
+ IpAddress = service.Spec.ClusterIP;
+ }
+
+ return (IpAddress, service.Spec.Ports.FirstOrDefault());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Operator/IKubernetesOperator.cs b/src/HealthChecks.UI.K8s.Operator/Operator/IKubernetesOperator.cs
new file mode 100644
index 0000000000..13e043fc88
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Operator/IKubernetesOperator.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+ internal interface IKubernetesOperator : IDisposable
+ {
+ Task RunAsync(CancellationToken token);
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Operator/KubernetesAddressFactory.cs b/src/HealthChecks.UI.K8s.Operator/Operator/KubernetesAddressFactory.cs
new file mode 100644
index 0000000000..2afe6d039a
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Operator/KubernetesAddressFactory.cs
@@ -0,0 +1,87 @@
+using k8s.Models;
+using System;
+using System.Linq;
+
+namespace HealthChecks.UI.K8s.Operator.Operator
+{
+ class KubernetesAddressFactory
+ {
+ public static string CreateAddress(V1Service service, HealthCheckResource resource)
+ {
+ var defaultPort = int.Parse(resource.Spec.PortNumber ?? Constants.DefaultPort);
+
+ var port = service.Spec.Type switch
+ {
+ ServiceType.LoadBalancer => GetServicePort(service)?.Port ?? defaultPort,
+ ServiceType.ClusterIP => GetServicePort(service)?.Port ?? defaultPort,
+ ServiceType.NodePort => GetServicePort(service)?.NodePort ?? defaultPort,
+ _ => throw new NotSupportedException($"{service.Spec.Type} port type not supported")
+ };
+
+ var address = service.Spec.Type switch
+ {
+ ServiceType.LoadBalancer => GetLoadBalancerAddress(service),
+ ServiceType.NodePort => GetLoadBalancerAddress(service),
+ ServiceType.ClusterIP => service.Spec.ClusterIP,
+ _ => throw new NotSupportedException($"{service.Spec.Type} port type not supported")
+ };
+
+ string healthScheme = resource.Spec.HealthChecksScheme;
+
+ if (service.Metadata.Annotations?.ContainsKey(Constants.HealthCheckSchemeAnnotation) ?? false)
+ {
+ healthScheme = service.Metadata.Annotations[Constants.HealthCheckSchemeAnnotation];
+ }
+
+ if (string.IsNullOrEmpty(healthScheme))
+ {
+ healthScheme = Constants.DefaultScheme;
+ }
+
+ if (address.Contains(":"))
+ {
+ return $"{healthScheme}://[{address}]:{port}";
+ }
+ else
+ {
+ return $"{healthScheme}://{address}:{port}";
+ }
+ }
+
+ public static string CreateHealthAddress(V1Service service, HealthCheckResource resource)
+ {
+ var address = CreateAddress(service, resource);
+
+ string healthPath = resource.Spec.HealthChecksPath;
+
+ if (service.Metadata.Annotations?.ContainsKey(Constants.HealthCheckPathAnnotation) ?? false)
+ {
+ healthPath = service.Metadata.Annotations[Constants.HealthCheckPathAnnotation];
+ }
+
+ if (string.IsNullOrEmpty(healthPath))
+ {
+ healthPath = Constants.DefaultHealthPath;
+ }
+
+ return $"{address}/{ healthPath.TrimStart('/')}";
+
+ }
+
+ private static string GetLoadBalancerAddress(V1Service service)
+ {
+ var ingress = service.Status.LoadBalancer?.Ingress?.FirstOrDefault();
+ if (ingress != null)
+ {
+ return ingress.Hostname ?? ingress.Ip;
+ }
+
+ return service.Spec.ClusterIP;
+ }
+
+ private static V1ServicePort GetServicePort(V1Service service)
+ {
+ return service.Spec.Ports.FirstOrDefault();
+ }
+ }
+}
diff --git a/src/HealthChecks.UI.K8s.Operator/Operator/ServiceType.cs b/src/HealthChecks.UI.K8s.Operator/Operator/ServiceType.cs
new file mode 100644
index 0000000000..f0b5eb984c
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Operator/ServiceType.cs
@@ -0,0 +1,9 @@
+namespace HealthChecks.UI.K8s.Operator
+{
+ public class ServiceType
+ {
+ public const string LoadBalancer = "LoadBalancer";
+ public const string ClusterIP = "ClusterIP";
+ public const string NodePort = "NodePort";
+ }
+}
\ No newline at end of file
diff --git a/src/HealthChecks.UI.K8s.Operator/Program.cs b/src/HealthChecks.UI.K8s.Operator/Program.cs
new file mode 100644
index 0000000000..77a7081c2f
--- /dev/null
+++ b/src/HealthChecks.UI.K8s.Operator/Program.cs
@@ -0,0 +1,74 @@
+using k8s;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using HealthChecks.UI.K8s.Operator.Controller;
+using HealthChecks.UI.K8s.Operator.Handlers;
+using Microsoft.Extensions.Logging;
+
+namespace HealthChecks.UI.K8s.Operator
+{
+
+ class Program
+ {
+ static void Main(string[] args)
+ {
+ var provider = InitializeProvider();
+
+ var logger = provider.GetService>();
+
+ var @operator = provider.GetRequiredService();
+
+ var cancelTokenSource = new CancellationTokenSource();
+
+ logger.LogInformation("Healthchecks Operator is starting...");
+
+ _ = @operator.RunAsync(cancelTokenSource.Token);
+
+ var reset = new ManualResetEventSlim(false);
+
+ Console.CancelKeyPress += (s, a) =>
+ {
+ logger.LogInformation("Healthchecks Operator is shutting down...");
+ @operator.Dispose();
+ cancelTokenSource.Cancel();
+
+ reset.Set();
+ };
+
+ reset.Wait();
+ }
+ private static IKubernetes GetKubernetesClient()
+ {
+ var config = KubernetesClientConfiguration.IsInCluster() ?
+ KubernetesClientConfiguration.InClusterConfig() :
+ KubernetesClientConfiguration.BuildConfigFromConfigFile();
+
+ return new Kubernetes(config);
+ }
+
+ private static IServiceProvider InitializeProvider()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging(config =>
+ {
+ config.AddConsole();
+ config.AddFilter(typeof(Program).Namespace, LogLevel.Information);
+ config.AddFilter("Microsoft", LogLevel.None);
+ });
+ services.AddSingleton(sp => GetKubernetesClient());
+ services.AddTransient();
+ services.AddTransient();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services.BuildServiceProvider();
+ }
+ }
+}
+
+public class K8sOperator { } //Dummy class for logging
+
diff --git a/src/HealthChecks.UI/Core/Data/HealthChecksDb.cs b/src/HealthChecks.UI/Core/Data/HealthChecksDb.cs
index b49896b45e..cbda583373 100644
--- a/src/HealthChecks.UI/Core/Data/HealthChecksDb.cs
+++ b/src/HealthChecks.UI/Core/Data/HealthChecksDb.cs
@@ -3,7 +3,7 @@
namespace HealthChecks.UI.Core.Data
{
- class HealthChecksDb
+ internal class HealthChecksDb
: DbContext
{
public DbSet Configurations { get; set; }
@@ -12,8 +12,8 @@ class HealthChecksDb
public DbSet Failures { get; set; }
- public DbSet HealthCheckExecutionEntries { get; set; }
-
+ public DbSet HealthCheckExecutionEntries { get; set; }
+
public DbSet HealthCheckExecutionHistories { get; set; }
public HealthChecksDb(DbContextOptions options) : base(options) { }
diff --git a/src/HealthChecks.UI/Core/HostedService/HealthCheckReportCollector.cs b/src/HealthChecks.UI/Core/HostedService/HealthCheckReportCollector.cs
index a6ad1a2bcd..97cccafd33 100644
--- a/src/HealthChecks.UI/Core/HostedService/HealthCheckReportCollector.cs
+++ b/src/HealthChecks.UI/Core/HostedService/HealthCheckReportCollector.cs
@@ -146,6 +146,12 @@ private async Task SaveExecutionHistory(HealthCheckConfiguration configuration,
if (execution != null)
{
+
+ if (execution.Uri != configuration.Uri)
+ {
+ UpdateUris(execution, configuration);
+ }
+
if (execution.Status == healthReport.Status)
{
_logger.LogDebug("HealthReport history already exists and is in the same state, updating the values.");
@@ -218,6 +224,12 @@ await _db.Executions
await _db.SaveChangesAsync();
}
+ private void UpdateUris(HealthCheckExecution execution, HealthCheckConfiguration configuration)
+ {
+ execution.Uri = configuration.Uri;
+ endpointAddresses.Remove(configuration.Id);
+ }
+
private void SaveExecutionHistoryEntries(UIHealthReport healthReport, HealthCheckExecution execution, DateTime lastExecutionTime)
{
diff --git a/src/HealthChecks.UI/__AssemblyOptions.cs b/src/HealthChecks.UI/__AssemblyOptions.cs
index 4717d59740..304d6d4e2d 100644
--- a/src/HealthChecks.UI/__AssemblyOptions.cs
+++ b/src/HealthChecks.UI/__AssemblyOptions.cs
@@ -2,3 +2,4 @@
[assembly: InternalsVisibleTo("UnitTests")]
[assembly: InternalsVisibleTo("FunctionalTests")]
+[assembly: InternalsVisibleTo("HealthChecks.UI.Image")]
diff --git a/src/HealthChecks.UI/package-lock.json b/src/HealthChecks.UI/package-lock.json
index 2b7b9e2957..9020576535 100644
--- a/src/HealthChecks.UI/package-lock.json
+++ b/src/HealthChecks.UI/package-lock.json
@@ -1679,7 +1679,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"aproba": {
"version": "1.2.0",
@@ -1700,12 +1701,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1720,17 +1723,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -1847,7 +1853,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"ini": {
"version": "1.3.5",
@@ -1859,6 +1866,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -1873,6 +1881,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -1880,12 +1889,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"minipass": {
"version": "2.2.4",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@@ -1904,6 +1915,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -1984,7 +1996,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -1996,6 +2009,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"wrappy": "1"
}
@@ -2081,7 +2095,8 @@
"safe-buffer": {
"version": "5.1.1",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -2117,6 +2132,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -2136,6 +2152,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
+ "optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -2179,12 +2196,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
},
"yallist": {
"version": "3.0.2",
"bundled": true,
- "dev": true
+ "dev": true,
+ "optional": true
}
}
},