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 } } },