diff --git a/examples/WebhookOperator/Program.cs b/examples/WebhookOperator/Program.cs index aa264da5..cf328b30 100644 --- a/examples/WebhookOperator/Program.cs +++ b/examples/WebhookOperator/Program.cs @@ -1,9 +1,14 @@ using KubeOps.Operator; +using KubeOps.Operator.Web.Builder; var builder = WebApplication.CreateBuilder(args); builder.Services .AddKubernetesOperator() - .RegisterComponents(); + .RegisterComponents() +#if DEBUG + .AddDevelopmentTunnel(5000) +#endif + ; builder.Services .AddControllers(); diff --git a/examples/WebhookOperator/appsettings.json b/examples/WebhookOperator/appsettings.json index 04dbb1eb..5868ff74 100644 --- a/examples/WebhookOperator/appsettings.json +++ b/examples/WebhookOperator/appsettings.json @@ -1,8 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Trace", - "Microsoft.AspNetCore": "Trace", + "Default": "Information", + "Microsoft.AspNetCore": "Information", "KubeOps": "Trace" } }, diff --git a/examples/WebhookOperator/webhook_configs.yaml b/examples/WebhookOperator/webhook_configs.yaml deleted file mode 100644 index 3f1821d1..00000000 --- a/examples/WebhookOperator/webhook_configs.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingWebhookConfiguration -metadata: - name: validation-webhooks -webhooks: - - admissionReviewVersions: - - v1 - clientConfig: - url: https://e9f2-85-195-221-58.ngrok-free.app/validate/v1testentity - matchPolicy: Exact - name: test.web.hook.com - rules: - - apiGroups: - - webhook.dev - apiVersions: - - v1 - operations: - - CREATE - - UPDATE - - DELETE - resources: - - testentitys - sideEffects: None - timeoutSeconds: 10 ---- -apiVersion: admissionregistration.k8s.io/v1 -kind: MutatingWebhookConfiguration -metadata: - name: mutation-webhooks -webhooks: - - admissionReviewVersions: - - v1 - clientConfig: - url: https://e9f2-85-195-221-58.ngrok-free.app/mutate/v1testentity - matchPolicy: Exact - name: test.web.hook.com - rules: - - apiGroups: - - webhook.dev - apiVersions: - - v1 - operations: - - CREATE - - UPDATE - - DELETE - resources: - - testentitys - sideEffects: None - timeoutSeconds: 10 diff --git a/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs new file mode 100644 index 00000000..6f580805 --- /dev/null +++ b/src/KubeOps.Operator.Web/Builder/OperatorBuilderExtensions.cs @@ -0,0 +1,47 @@ +using KubeOps.Abstractions.Builder; +using KubeOps.Operator.Web.LocalTunnel; + +using Microsoft.Extensions.DependencyInjection; + +namespace KubeOps.Operator.Web.Builder; + +/// +/// Method extensions for the operator builder to register web specific services. +/// +public static class OperatorBuilderExtensions +{ + /// + /// Adds a hosted service to the system that creates a "Local Tunnel" + /// (http://localtunnel.github.io/www/) to the running application. + /// The tunnel points to the configured host/port configuration and then + /// registers itself as webhook target within Kubernetes. This + /// enables developers to easily create webhooks without the requirement + /// of registering ngrok / localtunnel urls themselves. + /// + /// The operator builder. + /// The desired port that the asp.net application will run on. + /// The desired hostname. + /// The builder for chaining. + /// + /// Attach the development tunnel to the operator if in debug mode. + /// + /// var builder = WebApplication.CreateBuilder(args); + /// builder.Services + /// .AddKubernetesOperator() + /// .RegisterComponents() + /// #if DEBUG + /// .AddDevelopmentTunnel(5000) + /// #endif + /// ; + /// + /// + public static IOperatorBuilder AddDevelopmentTunnel( + this IOperatorBuilder builder, + ushort port, + string hostname = "localhost") + { + builder.Services.AddHostedService(); + builder.Services.AddSingleton(new TunnelConfig(hostname, port)); + return builder; + } +} diff --git a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj index be9e3c20..7ba46b02 100644 --- a/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj +++ b/src/KubeOps.Operator.Web/KubeOps.Operator.Web.csproj @@ -25,6 +25,7 @@ + diff --git a/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs new file mode 100644 index 00000000..5dd61aa0 --- /dev/null +++ b/src/KubeOps.Operator.Web/LocalTunnel/DevelopmentTunnelService.cs @@ -0,0 +1,130 @@ +using System.Reflection; + +using k8s; +using k8s.Models; + +using KubeOps.Operator.Client; +using KubeOps.Operator.Web.Webhooks.Mutation; +using KubeOps.Operator.Web.Webhooks.Validation; +using KubeOps.Transpiler; + +using Localtunnel; +using Localtunnel.Endpoints.Http; +using Localtunnel.Handlers.Kestrel; +using Localtunnel.Processors; +using Localtunnel.Tunnels; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace KubeOps.Operator.Web.LocalTunnel; + +internal class DevelopmentTunnelService : IHostedService +{ + private readonly TunnelConfig _config; + private readonly LocaltunnelClient _tunnelClient; + private Tunnel? _tunnel; + + public DevelopmentTunnelService(ILoggerFactory loggerFactory, TunnelConfig config) + { + _config = config; + _tunnelClient = new(loggerFactory); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _tunnel = await _tunnelClient.OpenAsync( + new KestrelTunnelConnectionHandler( + new HttpRequestProcessingPipelineBuilder() + .Append(new HttpHostHeaderRewritingRequestProcessor(_config.Hostname)).Build(), + new HttpTunnelEndpointFactory(_config.Hostname, _config.Port)), + cancellationToken: cancellationToken); + await _tunnel.StartAsync(cancellationToken: cancellationToken); + await RegisterValidators(_tunnel.Information.Url); + await RegisterMutators(_tunnel.Information.Url); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _tunnel?.Dispose(); + return Task.CompletedTask; + } + + private static async Task RegisterValidators(Uri uri) + { + var validationWebhooks = Assembly + .GetEntryAssembly()! + .DefinedTypes + .Where(t => t.BaseType?.IsGenericType == true && + t.BaseType?.GetGenericTypeDefinition() == typeof(ValidationWebhook<>)) + .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), + Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) + .Select(hook => new V1ValidatingWebhook + { + Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "*" }, + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + Url = $"{uri}validate/{hook.HookTypeName}", + }, + }); + + var validatorConfig = new V1ValidatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "dev-validators"), + webhooks: validationWebhooks.ToList()).Initialize(); + + using var validatorClient = KubernetesClientFactory.Create(); + await validatorClient.SaveAsync(validatorConfig); + } + + private static async Task RegisterMutators(Uri uri) + { + var mutationWebhooks = Assembly + .GetEntryAssembly()! + .DefinedTypes + .Where(t => t.BaseType?.IsGenericType == true && + t.BaseType?.GetGenericTypeDefinition() == typeof(MutationWebhook<>)) + .Select(t => (HookTypeName: t.BaseType!.GenericTypeArguments[0].Name.ToLowerInvariant(), + Entities.ToEntityMetadata(t.BaseType!.GenericTypeArguments[0]).Metadata)) + .Select(hook => new V1MutatingWebhook + { + Name = $"validate.{hook.Metadata.SingularName}.{hook.Metadata.Group}.{hook.Metadata.Version}", + MatchPolicy = "Exact", + AdmissionReviewVersions = new[] { "v1" }, + SideEffects = "None", + Rules = new[] + { + new V1RuleWithOperations + { + Operations = new[] { "*" }, + Resources = new[] { hook.Metadata.PluralName }, + ApiGroups = new[] { hook.Metadata.Group }, + ApiVersions = new[] { hook.Metadata.Version }, + }, + }, + ClientConfig = new Admissionregistrationv1WebhookClientConfig + { + Url = $"{uri}validate/{hook.HookTypeName}", + }, + }); + + var mutatorConfig = new V1MutatingWebhookConfiguration( + metadata: new V1ObjectMeta(name: "dev-mutators"), + webhooks: mutationWebhooks.ToList()).Initialize(); + + using var mutatorClient = KubernetesClientFactory.Create(); + await mutatorClient.SaveAsync(mutatorConfig); + } +} diff --git a/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs b/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs new file mode 100644 index 00000000..5d085589 --- /dev/null +++ b/src/KubeOps.Operator.Web/LocalTunnel/TunnelConfig.cs @@ -0,0 +1,3 @@ +namespace KubeOps.Operator.Web.LocalTunnel; + +internal record TunnelConfig(string Hostname, ushort Port);