From da6ed026420d3d7e026ed84b482198418d375abc Mon Sep 17 00:00:00 2001 From: Ciara Stacke Date: Wed, 16 Jul 2025 10:31:30 +0100 Subject: [PATCH] Add ability to patch dataplane Service, Deployment, and DaemonSet in NginxProxy --- apis/v1alpha2/nginxproxy_types.go | 45 ++++ apis/v1alpha2/zz_generated.deepcopy.go | 63 ++++- .../bases/gateway.nginx.org_nginxproxies.yaml | 78 ++++++ deploy/crds.yaml | 78 ++++++ go.mod | 2 +- internal/controller/provisioner/objects.go | 138 +++++++++-- .../controller/provisioner/objects_test.go | 229 ++++++++++++++++++ 7 files changed, 608 insertions(+), 25 deletions(-) diff --git a/apis/v1alpha2/nginxproxy_types.go b/apis/v1alpha2/nginxproxy_types.go index d1845411ab..a419df9fee 100644 --- a/apis/v1alpha2/nginxproxy_types.go +++ b/apis/v1alpha2/nginxproxy_types.go @@ -2,6 +2,7 @@ package v1alpha2 import ( corev1 "k8s.io/api/core/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" @@ -388,6 +389,35 @@ type KubernetesSpec struct { Service *ServiceSpec `json:"service,omitempty"` } +// Patch defines a patch to apply to a Kubernetes object. +type Patch struct { + // Type is the type of patch. Defaults to StrategicMerge. + // + // +optional + // +kubebuilder:default:=StrategicMerge + Type *PatchType `json:"type,omitempty"` + + // Value is the patch data as raw JSON. + // For StrategicMerge and Merge patches, this should be a JSON object. + // For JSONPatch patches, this should be a JSON array of patch operations. + // + // +kubebuilder:validation:XPreserveUnknownFields + Value *apiextv1.JSON `json:"value"` +} + +// PatchType specifies the type of patch. +// +kubebuilder:validation:Enum=StrategicMerge;Merge;JSONPatch +type PatchType string + +const ( + // PatchTypeStrategicMerge uses strategic merge patch. + PatchTypeStrategicMerge PatchType = "StrategicMerge" + // PatchTypeMerge uses merge patch (RFC 7386). + PatchTypeMerge PatchType = "Merge" + // PatchTypeJSONPatch uses JSON patch (RFC 6902). + PatchTypeJSONPatch PatchType = "JSONPatch" +) + // Deployment is the configuration for the NGINX Deployment. type DeploymentSpec struct { // Number of desired Pods. @@ -404,6 +434,11 @@ type DeploymentSpec struct { // // +optional Container ContainerSpec `json:"container"` + + // Patches are custom patches to apply to the NGINX Deployment. + // + // +optional + Patches []Patch `json:"patches,omitempty"` } // DaemonSet is the configuration for the NGINX DaemonSet. @@ -417,6 +452,11 @@ type DaemonSetSpec struct { // // +optional Container ContainerSpec `json:"container"` + + // Patches are custom patches to apply to the NGINX DaemonSet. + // + // +optional + Patches []Patch `json:"patches,omitempty"` } // PodSpec defines Pod-specific fields. @@ -569,6 +609,11 @@ type ServiceSpec struct { // // +optional NodePorts []NodePort `json:"nodePorts,omitempty"` + + // Patches are custom patches to apply to the NGINX Service. + // + // +optional + Patches []Patch `json:"patches,omitempty"` } // ServiceType describes ingress method for the Service. diff --git a/apis/v1alpha2/zz_generated.deepcopy.go b/apis/v1alpha2/zz_generated.deepcopy.go index 61eea4445a..de99741119 100644 --- a/apis/v1alpha2/zz_generated.deepcopy.go +++ b/apis/v1alpha2/zz_generated.deepcopy.go @@ -6,7 +6,8 @@ package v1alpha2 import ( "github.com/nginx/nginx-gateway-fabric/apis/v1alpha1" - "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" apisv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" ) @@ -26,12 +27,12 @@ func (in *ContainerSpec) DeepCopyInto(out *ContainerSpec) { } if in.Resources != nil { in, out := &in.Resources, &out.Resources - *out = new(v1.ResourceRequirements) + *out = new(corev1.ResourceRequirements) (*in).DeepCopyInto(*out) } if in.Lifecycle != nil { in, out := &in.Lifecycle, &out.Lifecycle - *out = new(v1.Lifecycle) + *out = new(corev1.Lifecycle) (*in).DeepCopyInto(*out) } if in.HostPorts != nil { @@ -41,7 +42,7 @@ func (in *ContainerSpec) DeepCopyInto(out *ContainerSpec) { } if in.VolumeMounts != nil { in, out := &in.VolumeMounts, &out.VolumeMounts - *out = make([]v1.VolumeMount, len(*in)) + *out = make([]corev1.VolumeMount, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -63,6 +64,13 @@ func (in *DaemonSetSpec) DeepCopyInto(out *DaemonSetSpec) { *out = *in in.Pod.DeepCopyInto(&out.Pod) in.Container.DeepCopyInto(&out.Container) + if in.Patches != nil { + in, out := &in.Patches, &out.Patches + *out = make([]Patch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DaemonSetSpec. @@ -85,6 +93,13 @@ func (in *DeploymentSpec) DeepCopyInto(out *DeploymentSpec) { } in.Pod.DeepCopyInto(&out.Pod) in.Container.DeepCopyInto(&out.Container) + if in.Patches != nil { + in, out := &in.Patches, &out.Patches + *out = make([]Patch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeploymentSpec. @@ -476,6 +491,31 @@ func (in *ObservabilityPolicySpec) DeepCopy() *ObservabilityPolicySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Patch) DeepCopyInto(out *Patch) { + *out = *in + if in.Type != nil { + in, out := &in.Type, &out.Type + *out = new(PatchType) + **out = **in + } + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Patch. +func (in *Patch) DeepCopy() *Patch { + if in == nil { + return nil + } + out := new(Patch) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PodSpec) DeepCopyInto(out *PodSpec) { *out = *in @@ -486,7 +526,7 @@ func (in *PodSpec) DeepCopyInto(out *PodSpec) { } if in.Affinity != nil { in, out := &in.Affinity, &out.Affinity - *out = new(v1.Affinity) + *out = new(corev1.Affinity) (*in).DeepCopyInto(*out) } if in.NodeSelector != nil { @@ -498,21 +538,21 @@ func (in *PodSpec) DeepCopyInto(out *PodSpec) { } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations - *out = make([]v1.Toleration, len(*in)) + *out = make([]corev1.Toleration, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.Volumes != nil { in, out := &in.Volumes, &out.Volumes - *out = make([]v1.Volume, len(*in)) + *out = make([]corev1.Volume, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.TopologySpreadConstraints != nil { in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints - *out = make([]v1.TopologySpreadConstraint, len(*in)) + *out = make([]corev1.TopologySpreadConstraint, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -607,6 +647,13 @@ func (in *ServiceSpec) DeepCopyInto(out *ServiceSpec) { *out = make([]NodePort, len(*in)) copy(*out, *in) } + if in.Patches != nil { + in, out := &in.Patches, &out.Patches + *out = make([]Patch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceSpec. diff --git a/config/crd/bases/gateway.nginx.org_nginxproxies.yaml b/config/crd/bases/gateway.nginx.org_nginxproxies.yaml index 8afed8b803..eadb714786 100644 --- a/config/crd/bases/gateway.nginx.org_nginxproxies.yaml +++ b/config/crd/bases/gateway.nginx.org_nginxproxies.yaml @@ -489,6 +489,32 @@ spec: type: object type: array type: object + patches: + description: Patches are custom patches to apply to the NGINX + DaemonSet. + items: + description: Patch defines a patch to apply to a Kubernetes + object. + properties: + type: + default: StrategicMerge + description: Type is the type of patch. Defaults to + StrategicMerge. + enum: + - StrategicMerge + - Merge + - JSONPatch + type: string + value: + description: |- + Value is the patch data as raw JSON. + For StrategicMerge and Merge patches, this should be a JSON object. + For JSONPatch patches, this should be a JSON array of patch operations. + x-kubernetes-preserve-unknown-fields: true + required: + - value + type: object + type: array pod: description: Pod defines Pod-specific fields. properties: @@ -3900,6 +3926,32 @@ spec: type: object type: array type: object + patches: + description: Patches are custom patches to apply to the NGINX + Deployment. + items: + description: Patch defines a patch to apply to a Kubernetes + object. + properties: + type: + default: StrategicMerge + description: Type is the type of patch. Defaults to + StrategicMerge. + enum: + - StrategicMerge + - Merge + - JSONPatch + type: string + value: + description: |- + Value is the patch data as raw JSON. + For StrategicMerge and Merge patches, this should be a JSON object. + For JSONPatch patches, this should be a JSON array of patch operations. + x-kubernetes-preserve-unknown-fields: true + required: + - value + type: object + type: array pod: description: Pod defines Pod-specific fields. properties: @@ -6952,6 +7004,32 @@ spec: - port type: object type: array + patches: + description: Patches are custom patches to apply to the NGINX + Service. + items: + description: Patch defines a patch to apply to a Kubernetes + object. + properties: + type: + default: StrategicMerge + description: Type is the type of patch. Defaults to + StrategicMerge. + enum: + - StrategicMerge + - Merge + - JSONPatch + type: string + value: + description: |- + Value is the patch data as raw JSON. + For StrategicMerge and Merge patches, this should be a JSON object. + For JSONPatch patches, this should be a JSON array of patch operations. + x-kubernetes-preserve-unknown-fields: true + required: + - value + type: object + type: array type: default: LoadBalancer description: ServiceType describes ingress method for the diff --git a/deploy/crds.yaml b/deploy/crds.yaml index 016c3074d3..e644769bc6 100644 --- a/deploy/crds.yaml +++ b/deploy/crds.yaml @@ -1074,6 +1074,32 @@ spec: type: object type: array type: object + patches: + description: Patches are custom patches to apply to the NGINX + DaemonSet. + items: + description: Patch defines a patch to apply to a Kubernetes + object. + properties: + type: + default: StrategicMerge + description: Type is the type of patch. Defaults to + StrategicMerge. + enum: + - StrategicMerge + - Merge + - JSONPatch + type: string + value: + description: |- + Value is the patch data as raw JSON. + For StrategicMerge and Merge patches, this should be a JSON object. + For JSONPatch patches, this should be a JSON array of patch operations. + x-kubernetes-preserve-unknown-fields: true + required: + - value + type: object + type: array pod: description: Pod defines Pod-specific fields. properties: @@ -4485,6 +4511,32 @@ spec: type: object type: array type: object + patches: + description: Patches are custom patches to apply to the NGINX + Deployment. + items: + description: Patch defines a patch to apply to a Kubernetes + object. + properties: + type: + default: StrategicMerge + description: Type is the type of patch. Defaults to + StrategicMerge. + enum: + - StrategicMerge + - Merge + - JSONPatch + type: string + value: + description: |- + Value is the patch data as raw JSON. + For StrategicMerge and Merge patches, this should be a JSON object. + For JSONPatch patches, this should be a JSON array of patch operations. + x-kubernetes-preserve-unknown-fields: true + required: + - value + type: object + type: array pod: description: Pod defines Pod-specific fields. properties: @@ -7537,6 +7589,32 @@ spec: - port type: object type: array + patches: + description: Patches are custom patches to apply to the NGINX + Service. + items: + description: Patch defines a patch to apply to a Kubernetes + object. + properties: + type: + default: StrategicMerge + description: Type is the type of patch. Defaults to + StrategicMerge. + enum: + - StrategicMerge + - Merge + - JSONPatch + type: string + value: + description: |- + Value is the patch data as raw JSON. + For StrategicMerge and Merge patches, this should be a JSON object. + For JSONPatch patches, this should be a JSON array of patch operations. + x-kubernetes-preserve-unknown-fields: true + required: + - value + type: object + type: array type: default: LoadBalancer description: ServiceType describes ingress method for the diff --git a/go.mod b/go.mod index 6ba4edb4de..0b1d866a61 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( golang.org/x/text v0.25.0 google.golang.org/grpc v1.72.2 google.golang.org/protobuf v1.36.6 + gopkg.in/evanphx/json-patch.v4 v4.12.0 k8s.io/api v0.33.2 k8s.io/apiextensions-apiserver v0.33.2 k8s.io/apimachinery v0.33.2 @@ -81,7 +82,6 @@ require ( gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect diff --git a/internal/controller/provisioner/objects.go b/internal/controller/provisioner/objects.go index 84cdefa02f..8056507e63 100644 --- a/internal/controller/provisioner/objects.go +++ b/internal/controller/provisioner/objects.go @@ -2,6 +2,7 @@ package provisioner import ( "context" + "encoding/json" "errors" "fmt" "maps" @@ -9,12 +10,14 @@ import ( "strconv" "time" + jsonpatch "gopkg.in/evanphx/json-patch.v4" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/strategicpatch" "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -46,6 +49,8 @@ func (p *NginxProvisioner) buildNginxResourceObjects( gateway *gatewayv1.Gateway, nProxyCfg *graph.EffectiveNginxProxy, ) ([]client.Object, error) { + var errs []error + // Need to ensure nginx resource objects are generated deterministically. Specifically when generating // an object's field by ranging over a map, since ranging over a map is done in random order, we need to // do some processing to ensure the generated results are the same each time. @@ -107,6 +112,9 @@ func (p *NginxProvisioner) buildNginxResourceObjects( caSecretName, clientSSLSecretName, ) + if err != nil { + errs = append(errs, err) + } configmaps := p.buildNginxConfigMaps( objectMeta, @@ -132,8 +140,12 @@ func (p *NginxProvisioner) buildNginxResourceObjects( ports[int32(listener.Port)] = struct{}{} } - service := buildNginxService(objectMeta, nProxyCfg, ports, selectorLabels) - deployment := p.buildNginxDeployment( + service, err := buildNginxService(objectMeta, nProxyCfg, ports, selectorLabels) + if err != nil { + errs = append(errs, err) + } + + deployment, err := p.buildNginxDeployment( objectMeta, nProxyCfg, ngxIncludesConfigMapName, @@ -146,6 +158,9 @@ func (p *NginxProvisioner) buildNginxResourceObjects( caSecretName, clientSSLSecretName, ) + if err != nil { + errs = append(errs, err) + } // order to install resources: // secrets @@ -164,7 +179,11 @@ func (p *NginxProvisioner) buildNginxResourceObjects( } objects = append(objects, service, deployment) - return objects, err + if len(errs) > 0 { + return objects, errors.Join(errs...) + } + + return objects, nil } func (p *NginxProvisioner) buildNginxSecrets( @@ -420,7 +439,7 @@ func buildNginxService( nProxyCfg *graph.EffectiveNginxProxy, ports map[int32]struct{}, selectorLabels map[string]string, -) *corev1.Service { +) (*corev1.Service, error) { var serviceCfg ngfAPIv1alpha2.ServiceSpec if nProxyCfg != nil && nProxyCfg.Kubernetes != nil && nProxyCfg.Kubernetes.Service != nil { serviceCfg = *nProxyCfg.Kubernetes.Service @@ -477,17 +496,16 @@ func buildNginxService( setIPFamily(nProxyCfg, svc) - if serviceCfg.LoadBalancerIP != nil { - svc.Spec.LoadBalancerIP = *serviceCfg.LoadBalancerIP - } - if serviceCfg.LoadBalancerClass != nil { - svc.Spec.LoadBalancerClass = serviceCfg.LoadBalancerClass - } - if serviceCfg.LoadBalancerSourceRanges != nil { - svc.Spec.LoadBalancerSourceRanges = serviceCfg.LoadBalancerSourceRanges + setSvcLoadBalancerSettings(serviceCfg, &svc.Spec) + + // Apply service patches + if nProxyCfg != nil && nProxyCfg.Kubernetes != nil && nProxyCfg.Kubernetes.Service != nil { + if err := applyPatches(svc, nProxyCfg.Kubernetes.Service.Patches); err != nil { + return svc, fmt.Errorf("failed to apply service patches: %w", err) + } } - return svc + return svc, nil } func setIPFamily(nProxyCfg *graph.EffectiveNginxProxy, svc *corev1.Service) { @@ -501,6 +519,18 @@ func setIPFamily(nProxyCfg *graph.EffectiveNginxProxy, svc *corev1.Service) { } } +func setSvcLoadBalancerSettings(svcCfg ngfAPIv1alpha2.ServiceSpec, svcSpec *corev1.ServiceSpec) { + if svcCfg.LoadBalancerIP != nil { + svcSpec.LoadBalancerIP = *svcCfg.LoadBalancerIP + } + if svcCfg.LoadBalancerClass != nil { + svcSpec.LoadBalancerClass = svcCfg.LoadBalancerClass + } + if svcCfg.LoadBalancerSourceRanges != nil { + svcSpec.LoadBalancerSourceRanges = svcCfg.LoadBalancerSourceRanges + } +} + func (p *NginxProvisioner) buildNginxDeployment( objectMeta metav1.ObjectMeta, nProxyCfg *graph.EffectiveNginxProxy, @@ -513,7 +543,7 @@ func (p *NginxProvisioner) buildNginxDeployment( jwtSecretName string, caSecretName string, clientSSLSecretName string, -) client.Object { +) (client.Object, error) { podTemplateSpec := p.buildNginxPodTemplateSpec( objectMeta, nProxyCfg, @@ -528,7 +558,7 @@ func (p *NginxProvisioner) buildNginxDeployment( ) if nProxyCfg != nil && nProxyCfg.Kubernetes != nil && nProxyCfg.Kubernetes.DaemonSet != nil { - return &appsv1.DaemonSet{ + daemonSet := &appsv1.DaemonSet{ ObjectMeta: objectMeta, Spec: appsv1.DaemonSetSpec{ Selector: &metav1.LabelSelector{ @@ -537,6 +567,15 @@ func (p *NginxProvisioner) buildNginxDeployment( Template: podTemplateSpec, }, } + + // Apply DaemonSet patches + if nProxyCfg.Kubernetes.DaemonSet != nil { + if err := applyPatches(daemonSet, nProxyCfg.Kubernetes.DaemonSet.Patches); err != nil { + return daemonSet, fmt.Errorf("failed to apply daemonset patches: %w", err) + } + } + + return daemonSet, nil } deployment := &appsv1.Deployment{ @@ -558,7 +597,74 @@ func (p *NginxProvisioner) buildNginxDeployment( deployment.Spec.Replicas = deploymentCfg.Replicas } - return deployment + // Apply Deployment patches + if nProxyCfg != nil && nProxyCfg.Kubernetes != nil && nProxyCfg.Kubernetes.Deployment != nil { + if err := applyPatches(deployment, nProxyCfg.Kubernetes.Deployment.Patches); err != nil { + return deployment, fmt.Errorf("failed to apply deployment patches: %w", err) + } + } + + return deployment, nil +} + +// applyPatches applies the provided patches to the given object. +func applyPatches(obj client.Object, patches []ngfAPIv1alpha2.Patch) error { + if len(patches) == 0 { + return nil + } + + // Convert object to JSON + objData, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal object: %w", err) + } + + // Apply each patch in sequence + for i, patch := range patches { + if patch.Value == nil || len(patch.Value.Raw) == 0 { + continue + } + patchType := ngfAPIv1alpha2.PatchTypeStrategicMerge + if patch.Type != nil { + patchType = *patch.Type + } + + patchData := patch.Value.Raw + var patchedData []byte + + switch patchType { + case ngfAPIv1alpha2.PatchTypeStrategicMerge: + patchedData, err = strategicpatch.StrategicMergePatch(objData, patchData, obj) + if err != nil { + return fmt.Errorf("failed to apply %s patch %d: %w", patchType, i, err) + } + case ngfAPIv1alpha2.PatchTypeMerge: + patchedData, err = jsonpatch.MergePatch(objData, patchData) + if err != nil { + return fmt.Errorf("failed to apply %s patch %d: %w", patchType, i, err) + } + case ngfAPIv1alpha2.PatchTypeJSONPatch: + jsonPatch, err := jsonpatch.DecodePatch(patchData) + if err != nil { + return fmt.Errorf("failed to decode json patch %d: %w", i, err) + } + patchedData, err = jsonPatch.Apply(objData) + if err != nil { + return fmt.Errorf("failed to apply %s patch %d: %w", patchType, i, err) + } + default: + return fmt.Errorf("unsupported patch type: %s", patchType) + } + + objData = patchedData + } + + // Unmarshal back to object + if err := json.Unmarshal(objData, obj); err != nil { + return fmt.Errorf("failed to unmarshal patched object: %w", err) + } + + return nil } //nolint:gocyclo // will refactor at some point diff --git a/internal/controller/provisioner/objects_test.go b/internal/controller/provisioner/objects_test.go index 608edf8b4d..a9de1be8c6 100644 --- a/internal/controller/provisioner/objects_test.go +++ b/internal/controller/provisioner/objects_test.go @@ -8,6 +8,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -1058,3 +1059,231 @@ func TestBuildNginxConfigMaps_WorkerConnections(t *testing.T) { g.Expect(ok).To(BeTrue()) g.Expect(bootstrapCM.Data["main.conf"]).To(ContainSubstring("worker_connections 2048;")) } + +func TestBuildNginxResourceObjects_Patches(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + agentTLSSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: agentTLSTestSecretName, + Namespace: ngfNamespace, + }, + Data: map[string][]byte{"tls.crt": []byte("tls")}, + } + fakeClient := fake.NewFakeClient(agentTLSSecret) + + provisioner := &NginxProvisioner{ + cfg: Config{ + GatewayPodConfig: &config.GatewayPodConfig{ + Namespace: ngfNamespace, + Version: "1.0.0", + Image: "ngf-image", + }, + AgentTLSSecretName: agentTLSTestSecretName, + }, + baseLabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "nginx", + }, + }, + k8sClient: fakeClient, + } + + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "default", + }, + Spec: gatewayv1.GatewaySpec{ + Listeners: []gatewayv1.Listener{ + {Port: 80}, + }, + }, + } + + // Test successful patches with all three resource types and all patch types + nProxyCfg := &graph.EffectiveNginxProxy{ + Kubernetes: &ngfAPIv1alpha2.KubernetesSpec{ + Service: &ngfAPIv1alpha2.ServiceSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeStrategicMerge), + Value: &apiextv1.JSON{ + Raw: []byte(`{"metadata":{"labels":{"svc-strategic":"true"}}}`), + }, + }, + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeMerge), + Value: &apiextv1.JSON{ + Raw: []byte(`{"metadata":{"labels":{"svc-merge":"true"}}}`), + }, + }, + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeJSONPatch), + Value: &apiextv1.JSON{ + Raw: []byte(`[{"op": "add", "path": "/metadata/labels/svc-json", "value": "true"}]`), + }, + }, + }, + }, + Deployment: &ngfAPIv1alpha2.DeploymentSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeStrategicMerge), + Value: &apiextv1.JSON{ + Raw: []byte(`{"metadata":{"labels":{"dep-patched":"true"}},"spec":{"replicas":3}}`), + }, + }, + }, + }, + }, + } + + objects, err := provisioner.buildNginxResourceObjects("gw-nginx", gateway, nProxyCfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(objects).To(HaveLen(6)) + + // Find and validate service + var svc *corev1.Service + for _, obj := range objects { + if s, ok := obj.(*corev1.Service); ok { + svc = s + break + } + } + g.Expect(svc).ToNot(BeNil()) + g.Expect(svc.Labels).To(HaveKeyWithValue("svc-strategic", "true")) + g.Expect(svc.Labels).To(HaveKeyWithValue("svc-merge", "true")) + g.Expect(svc.Labels).To(HaveKeyWithValue("svc-json", "true")) + + // Find and validate deployment + var dep *appsv1.Deployment + for _, obj := range objects { + if d, ok := obj.(*appsv1.Deployment); ok { + dep = d + break + } + } + g.Expect(dep).ToNot(BeNil()) + g.Expect(dep.Labels).To(HaveKeyWithValue("dep-patched", "true")) + g.Expect(dep.Spec.Replicas).To(Equal(helpers.GetPointer(int32(3)))) + + // Test successful daemonset patch + nProxyCfg = &graph.EffectiveNginxProxy{ + Kubernetes: &ngfAPIv1alpha2.KubernetesSpec{ + DaemonSet: &ngfAPIv1alpha2.DaemonSetSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeStrategicMerge), + Value: &apiextv1.JSON{ + Raw: []byte(`{"metadata":{"labels":{"ds-patched":"true"}}}`), + }, + }, + }, + }, + }, + } + + objects, err = provisioner.buildNginxResourceObjects("gw-nginx", gateway, nProxyCfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(objects).To(HaveLen(6)) + + // Find and validate daemonset + var ds *appsv1.DaemonSet + for _, obj := range objects { + if d, ok := obj.(*appsv1.DaemonSet); ok { + ds = d + break + } + } + g.Expect(ds).ToNot(BeNil()) + g.Expect(ds.Labels).To(HaveKeyWithValue("ds-patched", "true")) + + // Test error cases - invalid patches should return objects and errors + nProxyCfg = &graph.EffectiveNginxProxy{ + Kubernetes: &ngfAPIv1alpha2.KubernetesSpec{ + Service: &ngfAPIv1alpha2.ServiceSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeStrategicMerge), + Value: &apiextv1.JSON{ + Raw: []byte(`{"invalid json":`), + }, + }, + }, + }, + Deployment: &ngfAPIv1alpha2.DeploymentSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeJSONPatch), + Value: &apiextv1.JSON{ + Raw: []byte(`[{"op": "invalid", "path": "/test"}]`), + }, + }, + }, + }, + }, + } + + objects, err = provisioner.buildNginxResourceObjects("gw-nginx", gateway, nProxyCfg) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to apply service patches")) + g.Expect(err.Error()).To(ContainSubstring("failed to apply deployment patches")) + g.Expect(objects).To(HaveLen(6)) // Objects should still be returned + + // Test unsupported patch type + nProxyCfg = &graph.EffectiveNginxProxy{ + Kubernetes: &ngfAPIv1alpha2.KubernetesSpec{ + Service: &ngfAPIv1alpha2.ServiceSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchType("unsupported")), + Value: &apiextv1.JSON{ + Raw: []byte(`{"metadata":{"labels":{"test":"true"}}}`), + }, + }, + }, + }, + }, + } + + objects, err = provisioner.buildNginxResourceObjects("gw-nginx", gateway, nProxyCfg) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("unsupported patch type")) + g.Expect(objects).To(HaveLen(6)) + + // Test edge cases - nil values and empty patches should be ignored + nProxyCfg = &graph.EffectiveNginxProxy{ + Kubernetes: &ngfAPIv1alpha2.KubernetesSpec{ + Service: &ngfAPIv1alpha2.ServiceSpec{ + Patches: []ngfAPIv1alpha2.Patch{ + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeStrategicMerge), + Value: nil, // Should be ignored + }, + { + Type: helpers.GetPointer(ngfAPIv1alpha2.PatchTypeStrategicMerge), + Value: &apiextv1.JSON{ + Raw: []byte(""), // Should be ignored + }, + }, + }, + }, + }, + } + + objects, err = provisioner.buildNginxResourceObjects("gw-nginx", gateway, nProxyCfg) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(objects).To(HaveLen(6)) + + // Find service and verify no patches were applied + for _, obj := range objects { + if s, ok := obj.(*corev1.Service); ok { + svc = s + break + } + } + g.Expect(svc).ToNot(BeNil()) + g.Expect(svc.Labels).ToNot(HaveKey("patched")) // Should not have patch-related labels +}