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);