diff --git a/docs/primitives.md b/docs/primitives.md index 5142aa4a..0d66e65b 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -130,6 +130,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `PVSpecEditor` | PV spec fields — capacity, access modes, reclaim policy, storage class | | `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests | | `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration | +| `NetworkPolicySpecEditor` | Pod selector, ingress/egress rules, policy types | | `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the @@ -169,6 +170,7 @@ have been applied. This means a single mutation can safely add a container and t | `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) | | `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) | | `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) | +| `pkg/primitives/networkpolicy` | Static | [networkpolicy.md](primitives/networkpolicy.md) | ## Usage Examples diff --git a/docs/primitives/networkpolicy.md b/docs/primitives/networkpolicy.md new file mode 100644 index 00000000..66784802 --- /dev/null +++ b/docs/primitives/networkpolicy.md @@ -0,0 +1,284 @@ +# NetworkPolicy Primitive + +The `networkpolicy` primitive is the framework's built-in static abstraction for managing Kubernetes `NetworkPolicy` +resources. It integrates with the component lifecycle and provides a structured mutation API for managing pod selectors, +ingress rules, egress rules, and policy types. + +## Capabilities + +| Capability | Detail | +| --------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for NetworkPolicy spec and object metadata, with a raw escape hatch for free-form access | +| **Append semantics** | Ingress and egress rules have no unique key — `AppendIngressRule`/`AppendEgressRule` append unconditionally | +| **Data extraction** | Reads generated or updated values back from the reconciled NetworkPolicy after each sync cycle | + +## Building a NetworkPolicy Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/networkpolicy" + +base := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-netpol", + Namespace: owner.Namespace, + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": owner.Name}, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + networkingv1.PolicyTypeEgress, + }, + }, +} + +resource, err := networkpolicy.NewBuilder(base). + WithMutation(HTTPIngressMutation()). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `NetworkPolicy` beyond its baseline. Each mutation is a named +function that receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally — prefer this +for mutations that should always run and do not need feature-gate evaluation: + +```go +func HTTPIngressMutation() networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "http-ingress", + // Feature is nil — mutation is applied unconditionally. + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. + +### Boolean-gated mutations + +```go +func MetricsIngressMutation(version string, enableMetrics bool) networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "metrics-ingress", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(9090) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyNetworkPolicyMutation(version string) networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "legacy-policy", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.SetPolicyTypes([]networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + }) + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are +recorded: + +| Step | Category | What it affects | +| ---- | -------------- | --------------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `NetworkPolicy` | +| 2 | Spec edits | Pod selector, ingress rules, egress rules, policy types via Raw | + +Within each category, edits are applied in their registration order. Later features observe the NetworkPolicy as +modified by all previous features. + +## Editors + +### NetworkPolicySpecEditor + +The primary API for modifying the NetworkPolicy spec. Use `m.EditNetworkPolicySpec` for full control: + +```go +m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.SetPodSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + }) + port := intstr.FromInt32(80) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil +}) +``` + +#### SetPodSelector + +Sets the pod selector that determines which pods the policy applies to within the namespace. An empty `LabelSelector` +matches all pods. + +#### AppendIngressRule and AppendEgressRule + +Append a rule unconditionally. Ingress and egress rules have no unique key, so these methods always append. To replace +the full set of rules atomically, call `RemoveIngressRules` or `RemoveEgressRules` first: + +```go +m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + // Replace all ingress rules atomically. + e.RemoveIngressRules() + e.AppendIngressRule(newRule1) + e.AppendIngressRule(newRule2) + return nil +}) +``` + +#### RemoveIngressRules and RemoveEgressRules + +Clear all ingress or egress rules respectively. Use before `AppendIngressRule`/`AppendEgressRule` to replace the full +set atomically. + +#### SetPolicyTypes + +Sets the policy types. Valid values are `networkingv1.PolicyTypeIngress` and `networkingv1.PolicyTypeEgress`. When +`Egress` is included, egress rules must be set explicitly to permit traffic; an empty list denies all egress. + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*networkingv1.NetworkPolicySpec` for free-form editing when none of the structured +methods are sufficient: + +```go +m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + raw := e.Raw() + if raw.PodSelector.MatchLabels == nil { + raw.PodSelector.MatchLabels = make(map[string]string) + } + raw.PodSelector.MatchLabels["role"] = "db" + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("policy/managed-by", "operator") + return nil +}) +``` + +## Full Example: Feature-Composed Network Policy + +```go +func HTTPIngressMutation() networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "http-ingress", + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} + +func MetricsIngressMutation(version string, enabled bool) networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "metrics-ingress", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(9090) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} + +resource, err := networkpolicy.NewBuilder(base). + WithMutation(HTTPIngressMutation()). + WithMutation(MetricsIngressMutation(owner.Spec.Version, owner.Spec.EnableMetrics)). + Build() +``` + +When `EnableMetrics` is true, the final NetworkPolicy will have both HTTP and metrics ingress rules. When false, only +the HTTP rule is present. Neither mutation needs to know about the other. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. + +**Use `RemoveIngressRules`/`RemoveEgressRules` for atomic replacement.** Since rules have no unique key, there is no +upsert-by-key operation. To replace the full set of rules, call `Remove*Rules` first and then add the desired rules. +Alternatively, use `Raw()` for fine-grained manipulation. + +**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. Since +`AppendIngressRule`/`AppendEgressRule` append unconditionally, the order of registration determines the order of rules +in the resulting spec. diff --git a/examples/networkpolicy-primitive/README.md b/examples/networkpolicy-primitive/README.md new file mode 100644 index 00000000..5d1737ee --- /dev/null +++ b/examples/networkpolicy-primitive/README.md @@ -0,0 +1,35 @@ +# NetworkPolicy Primitive Example + +This example demonstrates the usage of the `networkpolicy` primitive within the operator component framework. It shows +how to manage a Kubernetes NetworkPolicy as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a NetworkPolicy with pod selector and policy types. +- **Feature Mutations**: Composing ingress and egress rules from independent, feature-gated mutations. +- **Boolean-Gated Rules**: Conditionally adding metrics ingress rules based on a spec flag. +- **Metadata Mutations**: Setting version labels on the NetworkPolicy via metadata editors. +- **Label Coexistence**: Demonstrating how label updates from this component can coexist with labels managed by other + controllers. +- **Data Extraction**: Reading the applied policy configuration after each reconcile cycle. + +## Directory Structure + +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from + `examples/shared/app`. +- `features/`: Contains modular feature definitions: + - `mutations.go`: HTTP ingress, boolean-gated metrics ingress, DNS egress, and version labelling. +- `resources/`: Contains the central `NewNetworkPolicyResource` factory that assembles all features using + `networkpolicy.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/networkpolicy-primitive/main.go +``` + +This will: + +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile through three spec variations, printing the applied policy details after each cycle. +4. Print the resulting status conditions. diff --git a/examples/networkpolicy-primitive/app/controller.go b/examples/networkpolicy-primitive/app/controller.go new file mode 100644 index 00000000..8bff6323 --- /dev/null +++ b/examples/networkpolicy-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the networkpolicy primitive. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewNetworkPolicyResource is a factory function to create the networkpolicy resource. + // This allows us to inject the resource construction logic. + NewNetworkPolicyResource func(*sharedapp.ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error { + // 1. Build the networkpolicy resource for this owner. + npResource, err := r.NewNetworkPolicyResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the networkpolicy. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(npResource, component.ResourceOptions{}). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/networkpolicy-primitive/features/mutations.go b/examples/networkpolicy-primitive/features/mutations.go new file mode 100644 index 00000000..92bb3edc --- /dev/null +++ b/examples/networkpolicy-primitive/features/mutations.go @@ -0,0 +1,91 @@ +// Package features provides sample mutations for the networkpolicy primitive example. +package features + +import ( + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/networkpolicy" +) + +// VersionLabelMutation sets the app.kubernetes.io/version label on the +// NetworkPolicy. It is always enabled (nil Feature gate). +func VersionLabelMutation(version string) networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "version-label", + Mutate: func(m *networkpolicy.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// HTTPIngressMutation adds an ingress rule allowing TCP traffic on port 8080. +// It is always enabled (nil Feature gate). +func HTTPIngressMutation() networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "http-ingress", + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} + +// MetricsIngressMutation adds an ingress rule allowing TCP traffic on port 9090 +// for Prometheus scraping. It is enabled when enableMetrics is true. +func MetricsIngressMutation(version string, enableMetrics bool) networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "metrics-ingress", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(9090) + tcp := corev1.ProtocolTCP + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} + +// DNSEgressMutation adds an egress rule allowing UDP traffic on port 53 for DNS +// resolution. It is always enabled (nil Feature gate). +func DNSEgressMutation() networkpolicy.Mutation { + return networkpolicy.Mutation{ + Name: "dns-egress", + Mutate: func(m *networkpolicy.Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + port := intstr.FromInt32(53) + udp := corev1.ProtocolUDP + e.AppendEgressRule(networkingv1.NetworkPolicyEgressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &udp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + } +} diff --git a/examples/networkpolicy-primitive/main.go b/examples/networkpolicy-primitive/main.go new file mode 100644 index 00000000..09b584bb --- /dev/null +++ b/examples/networkpolicy-primitive/main.go @@ -0,0 +1,110 @@ +// Package main is the entry point for the networkpolicy primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/networkpolicy-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/networkpolicy-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client. + scheme := runtime.NewScheme() + if err := sharedapp.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := networkingv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add networking/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&sharedapp.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize the controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + NewNetworkPolicyResource: resources.NewNetworkPolicyResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // feature-gated ingress rules compose from independent mutations. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: true, + EnableMetrics: false, // Disable metrics ingress + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Metrics=%v ---\n", + i+1, spec.Version, spec.EnableMetrics) + + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/networkpolicy-primitive/resources/networkpolicy.go b/examples/networkpolicy-primitive/resources/networkpolicy.go new file mode 100644 index 00000000..0825847d --- /dev/null +++ b/examples/networkpolicy-primitive/resources/networkpolicy.go @@ -0,0 +1,60 @@ +// Package resources provides resource implementations for the networkpolicy primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/networkpolicy-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/networkpolicy" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewNetworkPolicyResource constructs a networkpolicy primitive resource with all the features. +func NewNetworkPolicyResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base NetworkPolicy object. + base := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-netpol", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": owner.Name, + }, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + networkingv1.PolicyTypeEgress, + }, + }, + } + + // 2. Initialize the networkpolicy builder. + builder := networkpolicy.NewBuilder(base) + + // 3. Register mutations in dependency order. + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.HTTPIngressMutation()) + builder.WithMutation(features.MetricsIngressMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + builder.WithMutation(features.DNSEgressMutation()) + + // 4. Extract data from the reconciled NetworkPolicy. + builder.WithDataExtractor(func(np networkingv1.NetworkPolicy) error { + fmt.Printf("Reconciled NetworkPolicy: %s\n", np.Name) + fmt.Printf(" PodSelector: %v\n", np.Spec.PodSelector.MatchLabels) + fmt.Printf(" PolicyTypes: %v\n", np.Spec.PolicyTypes) + fmt.Printf(" IngressRules: %d\n", len(np.Spec.Ingress)) + fmt.Printf(" EgressRules: %d\n", len(np.Spec.Egress)) + return nil + }) + + // 5. Build the final resource. + return builder.Build() +} diff --git a/pkg/mutation/editors/networkpolicyspec.go b/pkg/mutation/editors/networkpolicyspec.go new file mode 100644 index 00000000..9afb600a --- /dev/null +++ b/pkg/mutation/editors/networkpolicyspec.go @@ -0,0 +1,81 @@ +package editors + +import ( + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NetworkPolicySpecEditor provides a typed API for mutating a Kubernetes +// NetworkPolicySpec. +// +// It exposes structured operations for managing the pod selector, ingress rules, +// egress rules, and policy types, as well as Raw() for free-form access when +// none of the structured methods are sufficient. +// +// Note: ingress and egress rules have no unique key. AppendIngressRule and +// AppendEgressRule append unconditionally. Use RemoveIngressRules or +// RemoveEgressRules to replace the full set atomically, or use Raw() for +// fine-grained manipulation. +type NetworkPolicySpecEditor struct { + spec *networkingv1.NetworkPolicySpec +} + +// NewNetworkPolicySpecEditor creates a new NetworkPolicySpecEditor wrapping the +// given NetworkPolicySpec pointer. +func NewNetworkPolicySpecEditor(spec *networkingv1.NetworkPolicySpec) *NetworkPolicySpecEditor { + return &NetworkPolicySpecEditor{spec: spec} +} + +// Raw returns the underlying *networkingv1.NetworkPolicySpec. +func (e *NetworkPolicySpecEditor) Raw() *networkingv1.NetworkPolicySpec { + return e.spec +} + +// SetPodSelector sets the pod selector for the NetworkPolicy. +// +// The selector determines which pods this policy applies to within the +// namespace. An empty LabelSelector matches all pods in the namespace. +func (e *NetworkPolicySpecEditor) SetPodSelector(selector metav1.LabelSelector) { + e.spec.PodSelector = selector +} + +// AppendIngressRule appends an ingress rule to the NetworkPolicy. +// +// Rules have no unique key, so this method always appends. To replace the +// full set of ingress rules atomically, call RemoveIngressRules first and +// then add the desired rules, or use Raw() for fine-grained manipulation. +func (e *NetworkPolicySpecEditor) AppendIngressRule(rule networkingv1.NetworkPolicyIngressRule) { + e.spec.Ingress = append(e.spec.Ingress, rule) +} + +// RemoveIngressRules clears all ingress rules from the NetworkPolicy. +// +// Use this before calling AppendIngressRule to replace the full set atomically. +func (e *NetworkPolicySpecEditor) RemoveIngressRules() { + e.spec.Ingress = nil +} + +// AppendEgressRule appends an egress rule to the NetworkPolicy. +// +// Rules have no unique key, so this method always appends. To replace the +// full set of egress rules atomically, call RemoveEgressRules first and +// then add the desired rules, or use Raw() for fine-grained manipulation. +func (e *NetworkPolicySpecEditor) AppendEgressRule(rule networkingv1.NetworkPolicyEgressRule) { + e.spec.Egress = append(e.spec.Egress, rule) +} + +// RemoveEgressRules clears all egress rules from the NetworkPolicy. +// +// Use this before calling AppendEgressRule to replace the full set atomically. +func (e *NetworkPolicySpecEditor) RemoveEgressRules() { + e.spec.Egress = nil +} + +// SetPolicyTypes sets the policy types for the NetworkPolicy. +// +// Valid values are networkingv1.PolicyTypeIngress and networkingv1.PolicyTypeEgress. +// When networkingv1.PolicyTypeEgress is included in PolicyTypes, the .Egress field +// (egress rules) must be set explicitly to permit traffic; an empty list denies all egress. +func (e *NetworkPolicySpecEditor) SetPolicyTypes(types []networkingv1.PolicyType) { + e.spec.PolicyTypes = types +} diff --git a/pkg/mutation/editors/networkpolicyspec_test.go b/pkg/mutation/editors/networkpolicyspec_test.go new file mode 100644 index 00000000..04c4250b --- /dev/null +++ b/pkg/mutation/editors/networkpolicyspec_test.go @@ -0,0 +1,137 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestNetworkPolicySpecEditor_Raw(t *testing.T) { + spec := &networkingv1.NetworkPolicySpec{} + e := NewNetworkPolicySpecEditor(spec) + assert.Same(t, spec, e.Raw()) +} + +func TestNetworkPolicySpecEditor_SetPodSelector(t *testing.T) { + spec := &networkingv1.NetworkPolicySpec{} + e := NewNetworkPolicySpecEditor(spec) + selector := metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + } + e.SetPodSelector(selector) + assert.Equal(t, selector, spec.PodSelector) +} + +func TestNetworkPolicySpecEditor_AppendIngressRule_Appends(t *testing.T) { + spec := &networkingv1.NetworkPolicySpec{} + e := NewNetworkPolicySpecEditor(spec) + + port80 := intstr.FromInt32(80) + port443 := intstr.FromInt32(443) + tcp := corev1.ProtocolTCP + + rule1 := networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}, + } + rule2 := networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port443}}, + } + + e.AppendIngressRule(rule1) + e.AppendIngressRule(rule2) + + assert.Len(t, spec.Ingress, 2) + assert.Equal(t, rule1, spec.Ingress[0]) + assert.Equal(t, rule2, spec.Ingress[1]) +} + +func TestNetworkPolicySpecEditor_RemoveIngressRules(t *testing.T) { + port80 := intstr.FromInt32(80) + tcp := corev1.ProtocolTCP + + spec := &networkingv1.NetworkPolicySpec{ + Ingress: []networkingv1.NetworkPolicyIngressRule{ + {Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}}, + }, + } + e := NewNetworkPolicySpecEditor(spec) + e.RemoveIngressRules() + assert.Nil(t, spec.Ingress) +} + +func TestNetworkPolicySpecEditor_AppendEgressRule_Appends(t *testing.T) { + spec := &networkingv1.NetworkPolicySpec{} + e := NewNetworkPolicySpecEditor(spec) + + port53 := intstr.FromInt32(53) + port443 := intstr.FromInt32(443) + udp := corev1.ProtocolUDP + tcp := corev1.ProtocolTCP + + rule1 := networkingv1.NetworkPolicyEgressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &udp, Port: &port53}}, + } + rule2 := networkingv1.NetworkPolicyEgressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port443}}, + } + + e.AppendEgressRule(rule1) + e.AppendEgressRule(rule2) + + assert.Len(t, spec.Egress, 2) + assert.Equal(t, rule1, spec.Egress[0]) + assert.Equal(t, rule2, spec.Egress[1]) +} + +func TestNetworkPolicySpecEditor_RemoveEgressRules(t *testing.T) { + port53 := intstr.FromInt32(53) + udp := corev1.ProtocolUDP + + spec := &networkingv1.NetworkPolicySpec{ + Egress: []networkingv1.NetworkPolicyEgressRule{ + {Ports: []networkingv1.NetworkPolicyPort{{Protocol: &udp, Port: &port53}}}, + }, + } + e := NewNetworkPolicySpecEditor(spec) + e.RemoveEgressRules() + assert.Nil(t, spec.Egress) +} + +func TestNetworkPolicySpecEditor_SetPolicyTypes(t *testing.T) { + spec := &networkingv1.NetworkPolicySpec{} + e := NewNetworkPolicySpecEditor(spec) + types := []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + networkingv1.PolicyTypeEgress, + } + e.SetPolicyTypes(types) + assert.Equal(t, types, spec.PolicyTypes) +} + +func TestNetworkPolicySpecEditor_ReplaceIngressAtomically(t *testing.T) { + port80 := intstr.FromInt32(80) + port443 := intstr.FromInt32(443) + port8080 := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + + spec := &networkingv1.NetworkPolicySpec{ + Ingress: []networkingv1.NetworkPolicyIngressRule{ + {Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}}, + {Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port443}}}, + }, + } + e := NewNetworkPolicySpecEditor(spec) + + e.RemoveIngressRules() + newRule := networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port8080}}, + } + e.AppendIngressRule(newRule) + + assert.Len(t, spec.Ingress, 1) + assert.Equal(t, newRule, spec.Ingress[0]) +} diff --git a/pkg/primitives/networkpolicy/builder.go b/pkg/primitives/networkpolicy/builder.go new file mode 100644 index 00000000..cc9102b0 --- /dev/null +++ b/pkg/primitives/networkpolicy/builder.go @@ -0,0 +1,80 @@ +package networkpolicy + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + networkingv1 "k8s.io/api/networking/v1" +) + +// Builder is a configuration helper for creating and customizing a NetworkPolicy +// Resource. +// +// It provides a fluent API for registering mutations and data extractors. +// Build() validates the configuration and returns an initialized Resource +// ready for use in a reconciliation loop. +type Builder struct { + base *generic.StaticBuilder[*networkingv1.NetworkPolicy, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided NetworkPolicy object. +// +// The NetworkPolicy object serves as the desired base state. During reconciliation +// the Resource will make the cluster's state match this base, modified by any +// registered mutations. +// +// The provided NetworkPolicy must have both Name and Namespace set, which is +// validated during the Build() call. +func NewBuilder(np *networkingv1.NetworkPolicy) *Builder { + identityFunc := func(n *networkingv1.NetworkPolicy) string { + return fmt.Sprintf("networking.k8s.io/v1/NetworkPolicy/%s/%s", n.Namespace, n.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*networkingv1.NetworkPolicy, *Mutator]( + np, + identityFunc, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the NetworkPolicy. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithDataExtractor registers a function to read values from the NetworkPolicy +// after it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled NetworkPolicy. This is +// useful for surfacing the applied policy rules to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(networkingv1.NetworkPolicy) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(np *networkingv1.NetworkPolicy) error { + return extractor(*np) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No NetworkPolicy object was provided. +// - The NetworkPolicy is missing a Name or Namespace. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/networkpolicy/builder_test.go b/pkg/primitives/networkpolicy/builder_test.go new file mode 100644 index 00000000..68bd89ed --- /dev/null +++ b/pkg/primitives/networkpolicy/builder_test.go @@ -0,0 +1,122 @@ +package networkpolicy + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder_Build_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + np *networkingv1.NetworkPolicy + expectedErr string + }{ + { + name: "nil networkpolicy", + np: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + np: &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + np: &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test-np"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid networkpolicy", + np: &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test-np", Namespace: "test-ns"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.np).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "networking.k8s.io/v1/NetworkPolicy/test-ns/test-np", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test-np", Namespace: "test-ns"}, + } + res, err := NewBuilder(np). + WithMutation(Mutation{Name: "test-mutation"}). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) +} + +func TestBuilder_WithDataExtractor(t *testing.T) { + t.Parallel() + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test-np", Namespace: "test-ns"}, + } + called := false + extractor := func(_ networkingv1.NetworkPolicy) error { + called = true + return nil + } + res, err := NewBuilder(np). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&networkingv1.NetworkPolicy{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test-np", Namespace: "test-ns"}, + } + res, err := NewBuilder(np). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test-np", Namespace: "test-ns"}, + } + res, err := NewBuilder(np). + WithDataExtractor(func(_ networkingv1.NetworkPolicy) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&networkingv1.NetworkPolicy{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/networkpolicy/mutator.go b/pkg/primitives/networkpolicy/mutator.go new file mode 100644 index 00000000..53ae2378 --- /dev/null +++ b/pkg/primitives/networkpolicy/mutator.go @@ -0,0 +1,126 @@ +// Package networkpolicy provides a builder and resource for managing Kubernetes NetworkPolicies. +package networkpolicy + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + networkingv1 "k8s.io/api/networking/v1" +) + +// Mutation defines a mutation that is applied to a networkpolicy Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + specEdits []func(*editors.NetworkPolicySpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes NetworkPolicy. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the NetworkPolicy in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Within a single feature plan, edits are applied in category order: metadata +// edits first, then spec edits. All edits within a category run in registration +// order. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + np *networkingv1.NetworkPolicy + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given NetworkPolicy. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the NetworkPolicy. If mutations are registered without an explicit +// BeginFeature call, a default feature plan is created automatically. +func NewMutator(np *networkingv1.NetworkPolicy) *Mutator { + return &Mutator{ + np: np, + } +} + +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) BeginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the NetworkPolicy's own metadata. +// +// Metadata edits are applied before spec edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.ensureActive() + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditNetworkPolicySpec records a mutation for the NetworkPolicy's spec via a +// NetworkPolicySpecEditor. +// +// The editor provides structured operations (SetPodSelector, AppendIngressRule, +// RemoveIngressRules, AppendEgressRule, RemoveEgressRules, SetPolicyTypes) as +// well as Raw() for free-form access. Spec edits are applied after metadata +// edits within the same feature, in registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditNetworkPolicySpec(edit func(*editors.NetworkPolicySpecEditor) error) { + if edit == nil { + return + } + m.ensureActive() + m.active.specEdits = append(m.active.specEdits, edit) +} + +// ensureActive starts a feature plan if one has not been started yet. +func (m *Mutator) ensureActive() { + if m.active == nil { + m.BeginFeature() + } +} + +// Apply executes all recorded mutation intents on the underlying NetworkPolicy. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Spec edits — EditNetworkPolicySpec (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the NetworkPolicy as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.np.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Spec edits + if len(plan.specEdits) > 0 { + editor := editors.NewNetworkPolicySpecEditor(&m.np.Spec) + for _, edit := range plan.specEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/networkpolicy/mutator_test.go b/pkg/primitives/networkpolicy/mutator_test.go new file mode 100644 index 00000000..5a929f51 --- /dev/null +++ b/pkg/primitives/networkpolicy/mutator_test.go @@ -0,0 +1,265 @@ +package networkpolicy + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" +) + +func newTestNP() *networkingv1.NetworkPolicy { + return &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-np", + Namespace: "default", + }, + } +} + +// --- EditObjectMetadata --- + +func TestNewMutator(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + assert.NotNil(t, m) + assert.Equal(t, np, m.np) + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestMutator_EditObjectMetadata(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", np.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditNetworkPolicySpec --- + +func TestMutator_EditNetworkPolicySpec_SetPodSelector(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.SetPodSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "web"}, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, map[string]string{"app": "web"}, np.Spec.PodSelector.MatchLabels) +} + +func TestMutator_EditNetworkPolicySpec_Nil(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditNetworkPolicySpec(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditNetworkPolicySpec_IngressRules(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + + port80 := intstr.FromInt32(80) + tcp := corev1.ProtocolTCP + + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, np.Spec.Ingress, 1) +} + +func TestMutator_EditNetworkPolicySpec_EgressRules(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + + port443 := intstr.FromInt32(443) + tcp := corev1.ProtocolTCP + + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendEgressRule(networkingv1.NetworkPolicyEgressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port443}}, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, np.Spec.Egress, 1) +} + +func TestMutator_EditNetworkPolicySpec_ReplaceIngressAtomically(t *testing.T) { + port80 := intstr.FromInt32(80) + port8080 := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + + np := newTestNP() + np.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{ + {Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}}, + } + + m := NewMutator(np) + m.BeginFeature() + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.RemoveIngressRules() + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port8080}}, + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, np.Spec.Ingress, 1) + assert.Equal(t, port8080.IntVal, np.Spec.Ingress[0].Ports[0].Port.IntVal) +} + +func TestMutator_EditNetworkPolicySpec_SetPolicyTypes(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.SetPolicyTypes([]networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + networkingv1.PolicyTypeEgress, + }) + return nil + }) + require.NoError(t, m.Apply()) + assert.Len(t, np.Spec.PolicyTypes, 2) +} + +func TestMutator_EditNetworkPolicySpec_Raw(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + raw := e.Raw() + raw.PodSelector = metav1.LabelSelector{ + MatchLabels: map[string]string{"role": "db"}, + } + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, map[string]string{"role": "db"}, np.Spec.PodSelector.MatchLabels) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + + port80 := intstr.FromInt32(80) + tcp := corev1.ProtocolTCP + + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}, + }) + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", np.Labels["order"]) + assert.Len(t, np.Spec.Ingress, 1) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + + port80 := intstr.FromInt32(80) + port443 := intstr.FromInt32(443) + tcp := corev1.ProtocolTCP + + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port80}}, + }) + return nil + }) + m.BeginFeature() + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendEgressRule(networkingv1.NetworkPolicyEgressRule{ + Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port443}}, + }) + return nil + }) + require.NoError(t, m.Apply()) + + assert.Len(t, np.Spec.Ingress, 1) + assert.Len(t, np.Spec.Egress, 1) +} + +// --- Error propagation --- + +func TestMutator_MetadataEditError(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return errors.New("metadata error") + }) + assert.Error(t, m.Apply()) +} + +func TestMutator_SpecEditError(t *testing.T) { + np := newTestNP() + m := NewMutator(np) + m.BeginFeature() + m.EditNetworkPolicySpec(func(_ *editors.NetworkPolicySpecEditor) error { + return errors.New("spec error") + }) + assert.Error(t, m.Apply()) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/networkpolicy/resource.go b/pkg/primitives/networkpolicy/resource.go new file mode 100644 index 00000000..d09b4048 --- /dev/null +++ b/pkg/primitives/networkpolicy/resource.go @@ -0,0 +1,56 @@ +package networkpolicy + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Resource is a high-level abstraction for managing a Kubernetes NetworkPolicy +// within a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - concepts.DataExtractable: for exporting values after successful reconciliation. +// +// NetworkPolicy resources are static: they do not model convergence health, grace +// periods, or suspension. Use a workload or task primitive for resources that +// require those concepts. +type Resource struct { + base *generic.StaticResource[*networkingv1.NetworkPolicy, *Mutator] +} + +// Identity returns a unique identifier for the NetworkPolicy in the format +// "networking.k8s.io/v1/NetworkPolicy//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes NetworkPolicy object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes NetworkPolicy into the +// desired state. +// +// The mutation process follows this order: +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled NetworkPolicy. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the NetworkPolicy. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/networkpolicy/resource_test.go b/pkg/primitives/networkpolicy/resource_test.go new file mode 100644 index 00000000..142b31cb --- /dev/null +++ b/pkg/primitives/networkpolicy/resource_test.go @@ -0,0 +1,250 @@ +package networkpolicy + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestResource_Identity(t *testing.T) { + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-netpol", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(np).Build() + require.NoError(t, err) + + assert.Equal(t, "networking.k8s.io/v1/NetworkPolicy/test-ns/test-netpol", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-netpol", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(np).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*networkingv1.NetworkPolicy) + require.True(t, ok) + assert.Equal(t, np.Name, got.Name) + assert.Equal(t, np.Namespace, got.Namespace) + + // Ensure it's a deep copy + got.Name = "changed" + assert.Equal(t, "test-netpol", np.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + } + + port := intstr.FromInt32(8080) + tcp := corev1.ProtocolTCP + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "test-mutation", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port}, + }, + }) + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*networkingv1.NetworkPolicy) + assert.Equal(t, "test", got.Labels["app"]) + assert.Equal(t, "test", got.Spec.PodSelector.MatchLabels["app"]) + require.Len(t, got.Spec.Ingress, 1) + require.Len(t, got.Spec.Ingress[0].Ports, 1) + assert.Equal(t, int32(8080), got.Spec.Ingress[0].Ports[0].Port.IntVal) +} + +func TestResource_Mutate_DisabledFeature(t *testing.T) { + desired := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + } + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "disabled-mutation", + Feature: feature.NewResourceFeature("v1", nil).When(false), + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("should-not", "appear") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*networkingv1.NetworkPolicy) + assert.NotContains(t, got.Labels, "should-not") +} + +func TestResource_Mutate_NilFeatureIsUnconditional(t *testing.T) { + desired := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "unconditional", + Mutate: func(m *Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("always", "present") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*networkingv1.NetworkPolicy) + assert.Equal(t, "present", got.Labels["always"]) +} + +func TestResource_Mutate_MutationOrdering(t *testing.T) { + desired := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + } + + port80 := intstr.FromInt32(80) + port443 := intstr.FromInt32(443) + tcp := corev1.ProtocolTCP + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "http", + Mutate: func(m *Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port80}, + }, + }) + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "https", + Mutate: func(m *Mutator) error { + m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error { + e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{ + Ports: []networkingv1.NetworkPolicyPort{ + {Protocol: &tcp, Port: &port443}, + }, + }) + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + + got := obj.(*networkingv1.NetworkPolicy) + require.Len(t, got.Spec.Ingress, 2) + assert.Equal(t, int32(80), got.Spec.Ingress[0].Ports[0].Port.IntVal) + assert.Equal(t, int32(443), got.Spec.Ingress[1].Ports[0].Port.IntVal) +} + +func TestResource_ExtractData(t *testing.T) { + np := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, + }, + }, + } + + extractedSelector := "" + res, err := NewBuilder(np). + WithDataExtractor(func(np networkingv1.NetworkPolicy) error { + extractedSelector = np.Spec.PodSelector.MatchLabels["app"] + return nil + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.NoError(t, err) + assert.Equal(t, "test", extractedSelector) +}