Skip to content

Commit 199e8ce

Browse files
feat: Add extension points for resource generation (#1118)
Closes #1114 ### Summary Introduces factory interfaces that let operators customize how CRDs, events, and webhook configurations are generated. All factories are registered with `TryAdd`, so users can register their own implementations before calling `AddKubernetesOperator()` / `AddDevelopmentTunnel()` / `UseCertificateProvider()` to override the defaults. ### New abstractions (`KubeOps.Abstractions`) | Interface | Purpose | |---|---| | `ICrdResourceFactory` | Controls how `V1CustomResourceDefinition` resources are created from entity types | | `IEventResourceFactory` | Controls how `Corev1Event` instances are constructed when publishing events | | `IWebhookConfigurationFactory` | Controls how `V1MutatingWebhookConfiguration` and `V1ValidatingWebhookConfiguration` are built | Supporting types: `MutatingWebhookRegistration`, `ValidatingWebhookRegistration` (records carrying entity metadata, URI, and CA bundle). ### Default implementations - **`KubeOpsCrdResourceFactory`** — Uses the transpiler with a single shared `MetadataLoadContext` across all entity types. Exposes `CreateCrdForEntityType()` as a virtual override point. - **`KubeOpsEventResourceFactory`** — Builds `Corev1Event` with a SHA-512 hashed name for Kubernetes naming compliance. - **`KubeOpsWebhookConfigurationFactory`** — Constructs webhook configs with virtual `CreateMutatingWebhook()` / `CreateValidatingWebhook()` methods for per-webhook customization. ### Other changes - **`IWebhookAttribute`** interface added to `KubeOps.Operator.Web` — implemented by `MutationWebhookAttribute`, `ValidationWebhookAttribute`, and `ConversionWebhookAttribute`. `WebhookServiceBase.GetWebHookUri()` falls back to `RouteAttribute.Template` for backward compatibility with custom attributes. - **`CrdInstaller`** refactored to accept `ICrdResourceFactory` via DI and includes retry logic with exponential backoff for transient API server errors. - **`KubeOpsEventPublisherFactory`** simplified — event construction delegated to `IEventResourceFactory`; the publisher only handles get-or-create, count increment, and save. - Event name hashing and `originalName` annotation logic moved from the publisher into `KubeOpsEventResourceFactory`. ### Breaking changes None. All modified constructors are `internal`. New interfaces and types are purely additive. ### Tests - `CrdInstallerTest` — verifies custom factory DI override, transient error retry, non-transient error handling, and stop cancellation. - `KubeOpsCrdResourceFactoryTest` — verifies default CRD transpilation. - `KubeOpsEventResourceFactoryTest` — verifies event properties and namespace defaulting. - `EventPublisherCustomResourceFactoryTest` — end-to-end test verifying custom factory output flows through the publisher to the Kubernetes client.
1 parent ec8370f commit 199e8ce

29 files changed

Lines changed: 1146 additions & 200 deletions

docs/docs/operator/advanced-configuration.mdx

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,262 @@ two events for *different* entities are never serialised by the UID lock; only e
608608
UID are affected by `ConflictStrategy`.
609609
:::
610610

611+
## Customizing CRD generation
612+
613+
:::note
614+
This section explains how you can customize CRD generation when using the built-in (runtime) `CrdInstaller`.
615+
CRD generation using the CLI is not affected.
616+
:::
617+
618+
KubeOps uses `ICrdResourceFactory` to generate `V1CustomResourceDefinition` resources from your entity types. The default implementation (`KubeOpsCrdResourceFactory`) uses the transpiler to produce CRDs, but you can replace or extend this behavior.
619+
620+
All factory interfaces are registered with `TryAddSingleton`, so registering your own implementation **before** calling `AddKubernetesOperator()` takes precedence over the default.
621+
622+
### Replacing the factory
623+
624+
Implement `ICrdResourceFactory` to take full control of CRD generation:
625+
626+
```csharp
627+
public class CustomCrdResourceFactory : ICrdResourceFactory
628+
{
629+
public IEnumerable<V1CustomResourceDefinition> CreateCustomResourceDefinitions(
630+
IReadOnlyCollection<Type> entityTypes)
631+
{
632+
// Build CRDs however you need — from YAML files, a database, or custom logic.
633+
foreach (var entityType in entityTypes)
634+
{
635+
yield return BuildCrdFromYaml(entityType);
636+
}
637+
}
638+
}
639+
```
640+
641+
Register it before the operator:
642+
643+
```csharp
644+
builder.Services.AddSingleton<ICrdResourceFactory, CustomCrdResourceFactory>();
645+
builder.Services.AddKubernetesOperator();
646+
```
647+
648+
### Extending the default factory
649+
650+
If you only need to tweak individual CRDs, inherit from `KubeOpsCrdResourceFactory` and override `CreateCrdForEntityType`:
651+
652+
```csharp
653+
public class AnnotatedCrdResourceFactory : KubeOpsCrdResourceFactory
654+
{
655+
protected override V1CustomResourceDefinition CreateCrdForEntityType(
656+
MetadataLoadContext context, Type entityType)
657+
{
658+
var crd = base.CreateCrdForEntityType(context, entityType);
659+
660+
// Add custom labels to every generated CRD
661+
crd.Metadata.Labels ??= new Dictionary<string, string>();
662+
crd.Metadata.Labels["managed-by"] = "my-operator";
663+
664+
return crd;
665+
}
666+
}
667+
```
668+
669+
Register it the same way:
670+
671+
```csharp
672+
builder.Services.AddSingleton<ICrdResourceFactory, AnnotatedCrdResourceFactory>();
673+
builder.Services.AddKubernetesOperator();
674+
```
675+
676+
## Customizing webhook configuration generation
677+
678+
KubeOps uses `IWebhookConfigurationFactory` to build the `V1MutatingWebhookConfiguration` and `V1ValidatingWebhookConfiguration` resources that are registered with the Kubernetes API server. The default implementation (`KubeOpsWebhookConfigurationFactory`) produces standard configurations, but you can replace or extend this behavior.
679+
680+
All factory interfaces are registered with `TryAddSingleton`, so registering your own implementation **before** calling `UseCertificateProvider()` or `AddDevelopmentTunnel()` takes precedence over the default.
681+
682+
### Replacing the Factory
683+
684+
Implement `IWebhookConfigurationFactory` to take full control:
685+
686+
```csharp
687+
public class CustomWebhookConfigurationFactory : IWebhookConfigurationFactory
688+
{
689+
public V1MutatingWebhookConfiguration CreateMutatingConfiguration(
690+
IEnumerable<MutatingWebhookRegistration> registrations)
691+
{
692+
// Build the configuration from scratch
693+
return new V1MutatingWebhookConfiguration
694+
{
695+
Metadata = new() { Name = "my-operator-mutators" },
696+
Webhooks = registrations.Select(r => new V1MutatingWebhook
697+
{
698+
Name = $"mutate.{r.Metadata.SingularName}",
699+
// ... your custom configuration
700+
}).ToList(),
701+
}.Initialize();
702+
}
703+
704+
public V1ValidatingWebhookConfiguration CreateValidatingConfiguration(
705+
IEnumerable<ValidatingWebhookRegistration> registrations)
706+
{
707+
return new V1ValidatingWebhookConfiguration
708+
{
709+
Metadata = new() { Name = "my-operator-validators" },
710+
Webhooks = registrations.Select(r => new V1ValidatingWebhook
711+
{
712+
Name = $"validate.{r.Metadata.SingularName}",
713+
// ... your custom configuration
714+
}).ToList(),
715+
}.Initialize();
716+
}
717+
}
718+
```
719+
720+
Register it before the operator web configuration:
721+
722+
```csharp
723+
builder.Services.AddSingleton<IWebhookConfigurationFactory, CustomWebhookConfigurationFactory>();
724+
builder.Services
725+
.AddKubernetesOperator()
726+
.RegisterComponents()
727+
.UseCertificateProvider(/* ... */);
728+
```
729+
730+
### Extending the Default Factory
731+
732+
If you only need to customize individual webhooks, inherit from `KubeOpsWebhookConfigurationFactory` and override `CreateMutatingWebhook` or `CreateValidatingWebhook`:
733+
734+
```csharp
735+
public class CustomWebhookFactory : KubeOpsWebhookConfigurationFactory
736+
{
737+
protected override V1MutatingWebhook CreateMutatingWebhook(MutatingWebhookRegistration reg)
738+
{
739+
var webhook = base.CreateMutatingWebhook(reg);
740+
741+
// Customize the failure policy
742+
webhook.FailurePolicy = "Ignore";
743+
744+
// Restrict to a specific namespace
745+
webhook.NamespaceSelector = new V1LabelSelector
746+
{
747+
MatchLabels = new Dictionary<string, string>
748+
{
749+
["kubernetes.io/metadata.name"] = "my-namespace",
750+
},
751+
};
752+
753+
return webhook;
754+
}
755+
}
756+
```
757+
758+
Register it the same way:
759+
760+
```csharp
761+
builder.Services.AddSingleton<IWebhookConfigurationFactory, CustomWebhookFactory>();
762+
763+
builder.Services
764+
.AddKubernetesOperator()
765+
.RegisterComponents()
766+
.UseCertificateProvider(/* ... */);
767+
```
768+
769+
Each registration record (`MutatingWebhookRegistration` / `ValidatingWebhookRegistration`) carries the entity `Metadata`, the webhook `Uri`, and the optional `CaBundle`, giving you all the information you need to build a fully customized webhook entry.
770+
771+
## Customizing event generation
772+
773+
KubeOps uses `IEventResourceFactory` to construct the `Corev1Event` instances that are published to Kubernetes. The default implementation (`KubeOpsEventResourceFactory`) creates events with a SHA-512 hashed name for Kubernetes naming compliance and populates standard fields from the operator settings. You can replace or extend this behavior.
774+
775+
The factory is registered with `TryAddSingleton`, so registering your own implementation **before** calling `AddKubernetesOperator()` takes precedence over the default.
776+
777+
### How the Default Works
778+
779+
The default factory:
780+
781+
1. Builds a composite string from the entity's UID, name, namespace, and the event's reason, message, and type.
782+
2. Hashes this string (SHA-512) to produce a deterministic, Kubernetes-compliant event name.
783+
3. Events with the same reason, message, and type for the same entity share the same name — the publisher increments `Count` and updates `LastTimestamp` instead of creating duplicates.
784+
785+
### Replacing the Factory
786+
787+
Implement `IEventResourceFactory` to take full control of event construction:
788+
789+
```csharp
790+
public class CustomEventResourceFactory(OperatorSettings settings) : IEventResourceFactory
791+
{
792+
public Corev1Event CreateEvent(
793+
IKubernetesObject<V1ObjectMeta> entity,
794+
string reason,
795+
string message,
796+
EventType type)
797+
{
798+
return new Corev1Event
799+
{
800+
Metadata = new()
801+
{
802+
Name = $"{entity.Name()}-{reason}-{Guid.NewGuid():N}",
803+
NamespaceProperty = entity.Namespace() ?? "default",
804+
Labels = new Dictionary<string, string>
805+
{
806+
["app.kubernetes.io/managed-by"] = settings.Name,
807+
},
808+
},
809+
Type = type.ToString(),
810+
Reason = reason,
811+
Message = message,
812+
ReportingComponent = settings.Name,
813+
ReportingInstance = Environment.MachineName,
814+
Source = new() { Component = settings.Name },
815+
InvolvedObject = entity.MakeObjectReference(),
816+
}.Initialize();
817+
}
818+
}
819+
```
820+
821+
Register it before the operator:
822+
823+
```csharp
824+
builder.Services.AddSingleton<IEventResourceFactory, CustomEventResourceFactory>();
825+
builder.Services.AddKubernetesOperator();
826+
```
827+
828+
Note that the above example will create a new distinct `Corev1Event` object for every event, because it is adding a new GUID to each event's name.
829+
830+
### Extending the Default Factory
831+
832+
Inherit from `KubeOpsEventResourceFactory` and override `CreateEvent` to add extra metadata while keeping the default naming and structure:
833+
834+
```csharp
835+
public class EnrichedEventResourceFactory(OperatorSettings settings, ILogger<EventPublisher> logger)
836+
: KubeOpsEventResourceFactory(settings, logger)
837+
{
838+
public override Corev1Event CreateEvent(
839+
IKubernetesObject<V1ObjectMeta> entity,
840+
string reason,
841+
string message,
842+
EventType type)
843+
{
844+
var evt = base.CreateEvent(entity, reason, message, type);
845+
846+
// Add custom annotations
847+
evt.Metadata.Annotations ??= new Dictionary<string, string>();
848+
evt.Metadata.Annotations["my-operator/version"] = "1.0.0";
849+
850+
return evt;
851+
}
852+
}
853+
```
854+
855+
Register it the same way:
856+
857+
```csharp
858+
builder.Services.AddSingleton<IEventResourceFactory, EnrichedEventResourceFactory>();
859+
builder.Services.AddKubernetesOperator();
860+
```
861+
862+
:::note
863+
The `EventPublisher` handles get-or-create logic, count incrementing, and timestamp updates. Your factory only controls the initial shape of the `Corev1Event` — the publisher manages its lifecycle.
864+
To completely replace how events are published, add your own `IEventPublisherFactory` implementation to the service collection before calling `AddKubernetesOperator`.
865+
:::
866+
611867
## Best Practices
612868

613869
### Finalizer Management

examples/Operator/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information.
44

5+
using KubeOps.Abstractions.Crds;
56
using KubeOps.Operator;
67

78
using Microsoft.Extensions.Hosting;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using k8s.Models;
6+
7+
namespace KubeOps.Abstractions.Crds;
8+
9+
/// <summary>
10+
/// Factory interface for creating <see cref="V1CustomResourceDefinition"/> instances.
11+
/// Implement this interface to customize the shape of CRDs generated by the operator.
12+
/// </summary>
13+
public interface ICrdResourceFactory
14+
{
15+
/// <summary>
16+
/// Creates the <see cref="V1CustomResourceDefinition"/> resources for the given entity types.
17+
/// </summary>
18+
/// <param name="entityTypes">The entity types to transpile into CRDs.</param>
19+
/// <returns>The generated <see cref="V1CustomResourceDefinition"/> instances.</returns>
20+
IEnumerable<V1CustomResourceDefinition> CreateCustomResourceDefinitions(IReadOnlyCollection<Type> entityTypes);
21+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using k8s;
6+
using k8s.Models;
7+
8+
namespace KubeOps.Abstractions.Events;
9+
10+
/// <summary>
11+
/// Factory interface for creating <see cref="Corev1Event"/> instances.
12+
/// Implement this interface to customize the shape of events published by the operator.
13+
/// </summary>
14+
/// <remarks>
15+
/// The default implementation of <see cref="EventPublisher"/> checks if an event with the same
16+
/// unique name already exists, and either updates the existing event or creates a new one accordingly.
17+
/// When updating an existing event, its <see cref="Corev1Event.Count"/> property is incremented and
18+
/// <see cref="Corev1Event.LastTimestamp"/> is updated to the current time.
19+
/// When creating a new event, <see cref="Corev1Event.Count"/> is set to 1 and <see cref="Corev1Event.FirstTimestamp"/>
20+
/// and <see cref="Corev1Event.LastTimestamp"/> are set to the current time.
21+
/// </remarks>
22+
public interface IEventResourceFactory
23+
{
24+
/// <summary>
25+
/// Creates a new <see cref="Corev1Event"/> for the given entity and event details.
26+
/// </summary>
27+
/// <param name="entity">The entity that is involved with the event.</param>
28+
/// <param name="reason">The reason string. This should be a machine readable reason string.</param>
29+
/// <param name="message">A human readable string for the event.</param>
30+
/// <param name="type">The <see cref="EventType"/> of the event.</param>
31+
/// <returns>A new <see cref="Corev1Event"/> instance.</returns>
32+
/// <remarks>
33+
/// The default implementation creates a unique event name by combining the entity's UID, name, namespace, reason,
34+
/// message, and type, and then hashing this combination to ensure it fits Kubernetes naming constraints.
35+
/// This means that events with the same reason, message, and type for the same entity will be considered the same
36+
/// event and will have their count incremented.
37+
/// </remarks>
38+
Corev1Event CreateEvent(IKubernetesObject<V1ObjectMeta> entity, string reason, string message, EventType type);
39+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using k8s.Models;
6+
7+
namespace KubeOps.Abstractions.Webhooks;
8+
9+
/// <summary>
10+
/// Factory interface for creating webhook configuration resources.
11+
/// Implement this interface to customize the shape of mutating and validating webhook configurations.
12+
/// </summary>
13+
public interface IWebhookConfigurationFactory
14+
{
15+
/// <summary>
16+
/// Creates a <see cref="V1MutatingWebhookConfiguration"/> from the given registrations.
17+
/// </summary>
18+
/// <param name="registrations">The collection of mutating webhook registrations.</param>
19+
/// <returns>The generated <see cref="V1MutatingWebhookConfiguration"/>.</returns>
20+
V1MutatingWebhookConfiguration CreateMutatingConfiguration(IEnumerable<MutatingWebhookRegistration> registrations);
21+
22+
/// <summary>
23+
/// Creates a <see cref="V1ValidatingWebhookConfiguration"/> from the given registrations.
24+
/// </summary>
25+
/// <param name="registrations">The collection of validating webhook registrations.</param>
26+
/// <returns>The generated <see cref="V1ValidatingWebhookConfiguration"/>.</returns>
27+
V1ValidatingWebhookConfiguration CreateValidatingConfiguration(IEnumerable<ValidatingWebhookRegistration> registrations);
28+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using KubeOps.Abstractions.Entities;
6+
7+
namespace KubeOps.Abstractions.Webhooks;
8+
9+
/// <summary>
10+
/// Registration data for a mutating webhook.
11+
/// </summary>
12+
/// <param name="Metadata">The entity metadata for the webhook's target resource.</param>
13+
/// <param name="Uri">The absolute URI the webhook is reachable at.</param>
14+
/// <param name="CaBundle">The PEM-encoded CA bundle for validating the webhook's certificate, or null.</param>
15+
public record MutatingWebhookRegistration(
16+
EntityMetadata Metadata,
17+
Uri Uri,
18+
byte[]? CaBundle);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using KubeOps.Abstractions.Entities;
6+
7+
namespace KubeOps.Abstractions.Webhooks;
8+
9+
/// <summary>
10+
/// Registration data for a validating webhook.
11+
/// </summary>
12+
/// <param name="Metadata">The entity metadata for the webhook's target resource.</param>
13+
/// <param name="Uri">The absolute URI the webhook is reachable at.</param>
14+
/// <param name="CaBundle">The PEM-encoded CA bundle for validating the webhook's certificate, or null.</param>
15+
public record ValidatingWebhookRegistration(
16+
EntityMetadata Metadata,
17+
Uri Uri,
18+
byte[]? CaBundle);

0 commit comments

Comments
 (0)