diff --git a/apis/v1alpha1/clientsettingspolicy_types.go b/apis/v1alpha1/clientsettingspolicy_types.go index 370a6e2287..cc19e0b027 100644 --- a/apis/v1alpha1/clientsettingspolicy_types.go +++ b/apis/v1alpha1/clientsettingspolicy_types.go @@ -95,7 +95,11 @@ type ClientKeepAlive struct { // Timeout defines the keep-alive timeouts for clients. // + // +kubebuilder:validation:XValidation:message="header can only be specified if server is specified",rule="!(has(self.header) && !has(self.server))" + // + // // +optional + //nolint:lll Timeout *ClientKeepAliveTimeout `json:"timeout,omitempty"` } diff --git a/apis/v1alpha1/policy_methods.go b/apis/v1alpha1/policy_methods.go new file mode 100644 index 0000000000..ebb616624b --- /dev/null +++ b/apis/v1alpha1/policy_methods.go @@ -0,0 +1,20 @@ +package v1alpha1 + +import ( + "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// FIXME(kate-osborn): Figure out a way to generate these methods for all our policies. +// These methods implement the policies.Policy interface which extends client.Object to add the following methods. + +func (p *ClientSettingsPolicy) GetTargetRef() v1alpha2.PolicyTargetReference { + return p.Spec.TargetRef +} + +func (p *ClientSettingsPolicy) GetPolicyStatus() v1alpha2.PolicyStatus { + return p.Status +} + +func (p *ClientSettingsPolicy) SetPolicyStatus(status v1alpha2.PolicyStatus) { + p.Status = status +} diff --git a/charts/nginx-gateway-fabric/templates/deployment.yaml b/charts/nginx-gateway-fabric/templates/deployment.yaml index dd272d91e9..4357cbc26a 100644 --- a/charts/nginx-gateway-fabric/templates/deployment.yaml +++ b/charts/nginx-gateway-fabric/templates/deployment.yaml @@ -122,6 +122,8 @@ spec: mountPath: /etc/nginx/secrets - name: nginx-run mountPath: /var/run/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes {{- with .Values.nginxGateway.extraVolumeMounts -}} {{ toYaml . | nindent 8 }} {{- end }} @@ -157,6 +159,8 @@ spec: mountPath: /var/cache/nginx - name: nginx-lib mountPath: /var/lib/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes {{- with .Values.nginx.extraVolumeMounts -}} {{ toYaml . | nindent 8 }} {{- end }} @@ -189,6 +193,8 @@ spec: emptyDir: {} - name: nginx-lib emptyDir: {} + - name: nginx-includes + emptyDir: {} {{- with .Values.extraVolumes -}} {{ toYaml . | nindent 6 }} {{- end }} diff --git a/charts/nginx-gateway-fabric/templates/rbac.yaml b/charts/nginx-gateway-fabric/templates/rbac.yaml index 851ddb42b7..6f65ad36b6 100644 --- a/charts/nginx-gateway-fabric/templates/rbac.yaml +++ b/charts/nginx-gateway-fabric/templates/rbac.yaml @@ -111,6 +111,7 @@ rules: - gateway.nginx.org resources: - nginxgateways + - clientsettingspolicies verbs: - get - list @@ -119,6 +120,7 @@ rules: - gateway.nginx.org resources: - nginxgateways/status + - clientsettingspolicies/status verbs: - update {{- if .Values.nginxGateway.leaderElection.enable }} diff --git a/config/crd/bases/gateway.nginx.org_clientsettingspolicies.yaml b/config/crd/bases/gateway.nginx.org_clientsettingspolicies.yaml index f34e80587a..ed7c5e35d8 100644 --- a/config/crd/bases/gateway.nginx.org_clientsettingspolicies.yaml +++ b/config/crd/bases/gateway.nginx.org_clientsettingspolicies.yaml @@ -108,6 +108,9 @@ spec: pattern: ^\d{1,4}(ms|s)?$ type: string type: object + x-kubernetes-validations: + - message: header can only be specified if server is specified + rule: '!(has(self.header) && !has(self.server))' type: object targetRef: description: |- diff --git a/conformance/provisioner/static-deployment.yaml b/conformance/provisioner/static-deployment.yaml index 58396a2a0a..c1b233220e 100644 --- a/conformance/provisioner/static-deployment.yaml +++ b/conformance/provisioner/static-deployment.yaml @@ -74,6 +74,8 @@ spec: mountPath: /etc/nginx/secrets - name: nginx-run mountPath: /var/run/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes - image: ghcr.io/nginxinc/nginx-gateway-fabric/nginx:edge imagePullPolicy: Always name: nginx @@ -102,6 +104,8 @@ spec: mountPath: /var/cache/nginx - name: nginx-lib mountPath: /var/lib/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes terminationGracePeriodSeconds: 30 serviceAccountName: nginx-gateway shareProcessNamespace: true @@ -119,3 +123,5 @@ spec: emptyDir: {} - name: nginx-lib emptyDir: {} + - name: nginx-includes + emptyDir: {} diff --git a/deploy/manifests/nginx-gateway-experimental.yaml b/deploy/manifests/nginx-gateway-experimental.yaml index 7cf2912885..626c94f0bf 100644 --- a/deploy/manifests/nginx-gateway-experimental.yaml +++ b/deploy/manifests/nginx-gateway-experimental.yaml @@ -93,6 +93,7 @@ rules: - gateway.nginx.org resources: - nginxgateways + - clientsettingspolicies verbs: - get - list @@ -101,6 +102,7 @@ rules: - gateway.nginx.org resources: - nginxgateways/status + - clientsettingspolicies/status verbs: - update - apiGroups: @@ -217,6 +219,8 @@ spec: mountPath: /etc/nginx/secrets - name: nginx-run mountPath: /var/run/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes - image: ghcr.io/nginxinc/nginx-gateway-fabric/nginx:edge imagePullPolicy: Always name: nginx @@ -245,6 +249,8 @@ spec: mountPath: /var/cache/nginx - name: nginx-lib mountPath: /var/lib/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes terminationGracePeriodSeconds: 30 serviceAccountName: nginx-gateway shareProcessNamespace: true @@ -262,6 +268,8 @@ spec: emptyDir: {} - name: nginx-lib emptyDir: {} + - name: nginx-includes + emptyDir: {} --- # Source: nginx-gateway-fabric/templates/gatewayclass.yaml apiVersion: gateway.networking.k8s.io/v1 diff --git a/deploy/manifests/nginx-gateway.yaml b/deploy/manifests/nginx-gateway.yaml index 933e66d4ec..63b01711db 100644 --- a/deploy/manifests/nginx-gateway.yaml +++ b/deploy/manifests/nginx-gateway.yaml @@ -90,6 +90,7 @@ rules: - gateway.nginx.org resources: - nginxgateways + - clientsettingspolicies verbs: - get - list @@ -98,6 +99,7 @@ rules: - gateway.nginx.org resources: - nginxgateways/status + - clientsettingspolicies/status verbs: - update - apiGroups: @@ -213,6 +215,8 @@ spec: mountPath: /etc/nginx/secrets - name: nginx-run mountPath: /var/run/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes - image: ghcr.io/nginxinc/nginx-gateway-fabric/nginx:edge imagePullPolicy: Always name: nginx @@ -241,6 +245,8 @@ spec: mountPath: /var/cache/nginx - name: nginx-lib mountPath: /var/lib/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes terminationGracePeriodSeconds: 30 serviceAccountName: nginx-gateway shareProcessNamespace: true @@ -258,6 +264,8 @@ spec: emptyDir: {} - name: nginx-lib emptyDir: {} + - name: nginx-includes + emptyDir: {} --- # Source: nginx-gateway-fabric/templates/gatewayclass.yaml apiVersion: gateway.networking.k8s.io/v1 diff --git a/deploy/manifests/nginx-plus-gateway-experimental.yaml b/deploy/manifests/nginx-plus-gateway-experimental.yaml index 88b5249c34..8d31f88e9b 100644 --- a/deploy/manifests/nginx-plus-gateway-experimental.yaml +++ b/deploy/manifests/nginx-plus-gateway-experimental.yaml @@ -99,6 +99,7 @@ rules: - gateway.nginx.org resources: - nginxgateways + - clientsettingspolicies verbs: - get - list @@ -107,6 +108,7 @@ rules: - gateway.nginx.org resources: - nginxgateways/status + - clientsettingspolicies/status verbs: - update - apiGroups: @@ -224,6 +226,8 @@ spec: mountPath: /etc/nginx/secrets - name: nginx-run mountPath: /var/run/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes - image: nginx-gateway-fabric/nginx-plus:edge imagePullPolicy: Always name: nginx @@ -252,6 +256,8 @@ spec: mountPath: /var/cache/nginx - name: nginx-lib mountPath: /var/lib/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes terminationGracePeriodSeconds: 30 serviceAccountName: nginx-gateway shareProcessNamespace: true @@ -269,6 +275,8 @@ spec: emptyDir: {} - name: nginx-lib emptyDir: {} + - name: nginx-includes + emptyDir: {} --- # Source: nginx-gateway-fabric/templates/gatewayclass.yaml apiVersion: gateway.networking.k8s.io/v1 diff --git a/deploy/manifests/nginx-plus-gateway.yaml b/deploy/manifests/nginx-plus-gateway.yaml index e0de54f541..b37e2fbba6 100644 --- a/deploy/manifests/nginx-plus-gateway.yaml +++ b/deploy/manifests/nginx-plus-gateway.yaml @@ -96,6 +96,7 @@ rules: - gateway.nginx.org resources: - nginxgateways + - clientsettingspolicies verbs: - get - list @@ -104,6 +105,7 @@ rules: - gateway.nginx.org resources: - nginxgateways/status + - clientsettingspolicies/status verbs: - update - apiGroups: @@ -220,6 +222,8 @@ spec: mountPath: /etc/nginx/secrets - name: nginx-run mountPath: /var/run/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes - image: nginx-gateway-fabric/nginx-plus:edge imagePullPolicy: Always name: nginx @@ -248,6 +252,8 @@ spec: mountPath: /var/cache/nginx - name: nginx-lib mountPath: /var/lib/nginx + - name: nginx-includes + mountPath: /etc/nginx/includes terminationGracePeriodSeconds: 30 serviceAccountName: nginx-gateway shareProcessNamespace: true @@ -265,6 +271,8 @@ spec: emptyDir: {} - name: nginx-lib emptyDir: {} + - name: nginx-includes + emptyDir: {} --- # Source: nginx-gateway-fabric/templates/gatewayclass.yaml apiVersion: gateway.networking.k8s.io/v1 diff --git a/examples/client-settings-policy/README.md b/examples/client-settings-policy/README.md new file mode 100644 index 0000000000..eb153e84e0 --- /dev/null +++ b/examples/client-settings-policy/README.md @@ -0,0 +1,5 @@ +TODO(kate-osborn): remove before merging to main + +# Client Settings Policy + +This contains examples for testing Client Settings Policy. diff --git a/examples/client-settings-policy/cafe-routes.yaml b/examples/client-settings-policy/cafe-routes.yaml new file mode 100644 index 0000000000..77b609d817 --- /dev/null +++ b/examples/client-settings-policy/cafe-routes.yaml @@ -0,0 +1,43 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: coffee +spec: + parentRefs: + - name: gateway + sectionName: http + - name: gateway + sectionName: http2 + hostnames: + - "cafe.example.com" + - "cafe.example.org" + rules: + - matches: + - path: + type: PathPrefix + value: /coffee + backendRefs: + - name: coffee + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: tea +spec: + parentRefs: + - name: gateway + sectionName: http + - name: gateway + sectionName: http2 + hostnames: + - "cafe.example.com" + - "cafe.example.org" + rules: + - matches: + - path: + type: Exact + value: /tea + backendRefs: + - name: tea + port: 80 diff --git a/examples/client-settings-policy/cafe.yaml b/examples/client-settings-policy/cafe.yaml new file mode 100644 index 0000000000..2d03ae59ff --- /dev/null +++ b/examples/client-settings-policy/cafe.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: coffee +spec: + replicas: 1 + selector: + matchLabels: + app: coffee + template: + metadata: + labels: + app: coffee + spec: + containers: + - name: coffee + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: coffee +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: coffee +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tea +spec: + replicas: 1 + selector: + matchLabels: + app: tea + template: + metadata: + labels: + app: tea + spec: + containers: + - name: tea + image: nginxdemos/nginx-hello:plain-text + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: tea +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: tea diff --git a/examples/client-settings-policy/conflict.yaml b/examples/client-settings-policy/conflict.yaml new file mode 100644 index 0000000000..da10513e3a --- /dev/null +++ b/examples/client-settings-policy/conflict.yaml @@ -0,0 +1,83 @@ +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: keepalive-requests + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + keepAlive: + requests: 100 +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: body-max-size + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + body: + maxSize: 10m +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: body-timeout + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + body: + timeout: 30s +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: keepalive-rest + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + keepAlive: + time: 5s + timeout: + server: 2s + header: 1s +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: zzzzzz-conflict + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + keepAlive: + time: 5s + timeout: + server: 2s + header: 1s +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: zzzzzz-conflict-2 + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + body: + maxSize: 50m diff --git a/examples/client-settings-policy/gateway.yaml b/examples/client-settings-policy/gateway.yaml new file mode 100644 index 0000000000..5404ac397c --- /dev/null +++ b/examples/client-settings-policy/gateway.yaml @@ -0,0 +1,15 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway +spec: + gatewayClassName: nginx + listeners: + - name: http + port: 80 + protocol: HTTP + hostname: "*.example.com" + - name: http2 + port: 8080 + protocol: HTTP + hostname: "*.example.org" diff --git a/examples/client-settings-policy/inherited.yaml b/examples/client-settings-policy/inherited.yaml new file mode 100644 index 0000000000..7758a36e5d --- /dev/null +++ b/examples/client-settings-policy/inherited.yaml @@ -0,0 +1,46 @@ +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: gw + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + body: + maxSize: 10m + timeout: 30s + keepAlive: + requests: 100 + time: 5s + timeout: + server: 2s + header: 1s +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: tea-route + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: tea + body: + maxSize: 800m +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: coffee-route + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: coffee + keepAlive: + requests: 60 + time: 7s diff --git a/examples/client-settings-policy/merge.yaml b/examples/client-settings-policy/merge.yaml new file mode 100644 index 0000000000..eee032d8fc --- /dev/null +++ b/examples/client-settings-policy/merge.yaml @@ -0,0 +1,54 @@ +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: keepalive-requests + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + keepAlive: + requests: 100 +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: body-max-size + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + body: + maxSize: 10m +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: body-timeout + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + body: + timeout: 30s +--- +apiVersion: gateway.nginx.org/v1alpha1 +kind: ClientSettingsPolicy +metadata: + name: keepalive-rest + namespace: default +spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway + keepAlive: + time: 5s + timeout: + server: 2s + header: 1s diff --git a/internal/mode/static/handler.go b/internal/mode/static/handler.go index 03480bb9d0..0ecaf304f6 100644 --- a/internal/mode/static/handler.go +++ b/internal/mode/static/handler.go @@ -82,6 +82,8 @@ type eventHandlerConfig struct { controlConfigNSName types.NamespacedName // updateGatewayClassStatus enables updating the status of the GatewayClass resource. updateGatewayClassStatus bool + // policyConfigGenerator generates configuration for an NGF Policy. + policyConfigGenerator dataplane.PolicyConfigGenerator } const ( @@ -193,7 +195,7 @@ func (h *eventHandlerImpl) HandleEventBatch(ctx context.Context, logger logr.Log return case state.EndpointsOnlyChange: h.version++ - cfg := dataplane.BuildConfiguration(ctx, graph, h.cfg.serviceResolver, h.version) + cfg := dataplane.BuildConfiguration(ctx, graph, h.cfg.serviceResolver, h.cfg.policyConfigGenerator, h.version) h.setLatestConfiguration(&cfg) @@ -204,7 +206,7 @@ func (h *eventHandlerImpl) HandleEventBatch(ctx context.Context, logger logr.Log ) case state.ClusterStateChange: h.version++ - cfg := dataplane.BuildConfiguration(ctx, graph, h.cfg.serviceResolver, h.version) + cfg := dataplane.BuildConfiguration(ctx, graph, h.cfg.serviceResolver, h.cfg.policyConfigGenerator, h.version) h.setLatestConfiguration(&cfg) @@ -247,11 +249,13 @@ func (h *eventHandlerImpl) updateStatuses(ctx context.Context, logger logr.Logge } routeReqs := status.PrepareRouteRequests(graph.Routes, transitionTime, h.latestReloadResult, h.cfg.gatewayCtlrName) polReqs := status.PrepareBackendTLSPolicyRequests(graph.BackendTLSPolicies, transitionTime, h.cfg.gatewayCtlrName) + ngfPolReqs := status.PrepareNGFPolicyRequests(graph.NGFPolicies, transitionTime, h.cfg.gatewayCtlrName) - reqs := make([]frameworkStatus.UpdateRequest, 0, len(gcReqs)+len(routeReqs)+len(polReqs)) + reqs := make([]frameworkStatus.UpdateRequest, 0, len(gcReqs)+len(routeReqs)+len(polReqs)+len(ngfPolReqs)) reqs = append(reqs, gcReqs...) reqs = append(reqs, routeReqs...) reqs = append(reqs, polReqs...) + reqs = append(reqs, ngfPolReqs...) h.cfg.statusUpdater.UpdateGroup(ctx, groupAllExceptGateways, reqs...) diff --git a/internal/mode/static/manager.go b/internal/mode/static/manager.go index d810584a69..b6ecddaac1 100644 --- a/internal/mode/static/manager.go +++ b/internal/mode/static/manager.go @@ -25,6 +25,7 @@ import ( "k8s.io/client-go/tools/record" ctlr "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" ctrlcfg "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics" @@ -50,6 +51,8 @@ import ( ngxvalidation "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/nginx/config/validation" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/nginx/file" ngxruntime "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/nginx/runtime" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies/clientsettings" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/resolver" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/validation" @@ -110,12 +113,24 @@ func StartManager(cfg config.Config) error { int32(cfg.HealthConfig.Port): "HealthPort", } + mustExtractGVK := func(obj client.Object) schema.GroupVersionKind { + gvk, err := apiutil.GVKForObject(obj, scheme) + if err != nil { + panic(fmt.Sprintf("could not extract GVK for object: %T", obj)) + } + + return gvk + } + + policyManager := createPolicyManager(mustExtractGVK) + processor := state.NewChangeProcessorImpl(state.ChangeProcessorConfig{ GatewayCtlrName: cfg.GatewayCtlrName, GatewayClassName: cfg.GatewayClassName, Logger: cfg.Logger.WithName("changeProcessor"), Validators: validation.Validators{ HTTPFieldsValidator: ngxvalidation.HTTPValidator{}, + PolicyValidator: policyManager, }, EventRecorder: recorder, Scheme: scheme, @@ -220,6 +235,7 @@ func StartManager(cfg config.Config) error { usageSecret: usageSecret, gatewayCtlrName: cfg.GatewayCtlrName, updateGatewayClassStatus: cfg.UpdateGatewayClassStatus, + policyConfigGenerator: policyManager, }) objects, objectLists := prepareFirstEventBatchPreparerArgs( @@ -271,6 +287,18 @@ func StartManager(cfg config.Config) error { return mgr.Start(ctx) } +func createPolicyManager(mustExtractGVK func(object client.Object) schema.GroupVersionKind) *policies.Manager { + cfgs := []policies.ManagerConfig{ + { + GVK: mustExtractGVK(&ngfAPI.ClientSettingsPolicy{}), + Validator: clientsettings.Validator{}, + Generator: clientsettings.NewClientSettingsGeneratorFunc(), + }, + } + + return policies.NewManager(mustExtractGVK, cfgs...) +} + func createManager(cfg config.Config, nginxChecker *nginxConfiguredOnStartChecker) (manager.Manager, error) { options := manager.Options{ Scheme: scheme, @@ -414,6 +442,12 @@ func registerControllers( ), }, }, + { + objectType: &ngfAPI.ClientSettingsPolicy{}, + options: []controller.Option{ + controller.WithK8sPredicate(k8spredicate.GenerationChangedPredicate{}), + }, + }, } if cfg.ExperimentalFeatures { @@ -592,6 +626,7 @@ func prepareFirstEventBatchPreparerArgs( &discoveryV1.EndpointSliceList{}, &gatewayv1.HTTPRouteList{}, &gatewayv1beta1.ReferenceGrantList{}, + &ngfAPI.ClientSettingsPolicyList{}, partialObjectMetadataList, } diff --git a/internal/mode/static/nginx/config/generator.go b/internal/mode/static/nginx/config/generator.go index 4917a80cac..fb5c8da205 100644 --- a/internal/mode/static/nginx/config/generator.go +++ b/internal/mode/static/nginx/config/generator.go @@ -17,16 +17,24 @@ const ( httpFolder = configFolder + "/conf.d" // secretsFolder is the folder where secrets (like TLS certs/keys) are stored. secretsFolder = configFolder + "/secrets" - - // httpConfigFile is the path to the configuration file with HTTP configuration. - httpConfigFile = httpFolder + "/http.conf" + // includesFolder is the folder where are all include files are stored. + includesFolder = configFolder + "/includes" + + // serversConfigFile is the path to the config file containing the http servers. + serversConfigFile = httpFolder + "/servers.conf" + // upstreamsConfigFile is the path to the config file containing the http upstreams. + upstreamsConfigFile = httpFolder + "/upstream.conf" + // mapsConfigFile is the path to the config file containing the http maps. + mapsConfigFile = httpFolder + "/maps.conf" + // splitClientsConfigFile is the path to the config file containing the split clients. + splitClientsConfigFile = httpFolder + "/split_clients.conf" // configVersionFile is the path to the config version configuration file. configVersionFile = httpFolder + "/config-version.conf" ) // ConfigFolders is a list of folders where NGINX configuration files are stored. -var ConfigFolders = []string{httpFolder, secretsFolder} +var ConfigFolders = []string{httpFolder, secretsFolder, includesFolder} // Generator generates NGINX configuration files. // This interface is used for testing purposes only. @@ -52,8 +60,13 @@ func NewGeneratorImpl(plus bool) GeneratorImpl { return GeneratorImpl{plus: plus} } +type executeResult struct { + dest string + data []byte +} + // executeFunc is a function that generates NGINX configuration from internal representation. -type executeFunc func(configuration dataplane.Configuration) []byte +type executeFunc func(configuration dataplane.Configuration) []executeResult // Generate generates NGINX configuration files from internal representation. // It is the responsibility of the caller to validate the configuration before calling this function. @@ -66,7 +79,7 @@ func (g GeneratorImpl) Generate(conf dataplane.Configuration) []file.File { files = append(files, generatePEM(id, pair.Cert, pair.Key)) } - files = append(files, g.generateHTTPConfig(conf)) + files = append(files, g.generateHTTPConfig(conf)...) files = append(files, generateConfigVersion(conf.Version)) @@ -106,17 +119,23 @@ func generateCertBundleFileName(id dataplane.CertBundleID) string { return filepath.Join(secretsFolder, string(id)+".crt") } -func (g GeneratorImpl) generateHTTPConfig(conf dataplane.Configuration) file.File { - var c []byte +func (g GeneratorImpl) generateHTTPConfig(conf dataplane.Configuration) []file.File { + files := make([]file.File, 0) + for _, execute := range g.getExecuteFuncs() { - c = append(c, execute(conf)...) - } + results := execute(conf) + + for _, res := range results { + files = append(files, file.File{ + Path: res.dest, + Content: res.data, + Type: file.TypeRegular, + }) + } - return file.File{ - Content: c, - Path: httpConfigFile, - Type: file.TypeRegular, } + + return files } func (g GeneratorImpl) getExecuteFuncs() []executeFunc { diff --git a/internal/mode/static/nginx/config/http/config.go b/internal/mode/static/nginx/config/http/config.go index 8486b7d464..8f58bad71b 100644 --- a/internal/mode/static/nginx/config/http/config.go +++ b/internal/mode/static/nginx/config/http/config.go @@ -8,6 +8,7 @@ type Server struct { IsDefaultHTTP bool IsDefaultSSL bool Port int32 + Includes []Include } // Location holds all configuration for an HTTP location. @@ -19,9 +20,10 @@ type Location struct { HTTPMatchVar string Rewrites []string ProxySetHeaders []Header + Includes []Include } -// Header defines a HTTP header to be passed to the proxied server. +// Header defines an HTTP header to be passed to the proxied server. type Header struct { Name string Value string @@ -93,3 +95,9 @@ type ProxySSLVerify struct { TrustedCertificate string Name string } + +// Include holds all the files to include using the include directive. +type Include struct { + Filename string + Content []byte +} diff --git a/internal/mode/static/nginx/config/maps.go b/internal/mode/static/nginx/config/maps.go index e3cb7d78a5..726ded1350 100644 --- a/internal/mode/static/nginx/config/maps.go +++ b/internal/mode/static/nginx/config/maps.go @@ -10,9 +10,14 @@ import ( var mapsTemplate = gotemplate.Must(gotemplate.New("maps").Parse(mapsTemplateText)) -func executeMaps(conf dataplane.Configuration) []byte { +func executeMaps(conf dataplane.Configuration) []executeResult { maps := buildAddHeaderMaps(append(conf.HTTPServers, conf.SSLServers...)) - return execute(mapsTemplate, maps) + result := executeResult{ + dest: mapsConfigFile, + data: execute(mapsTemplate, maps), + } + + return []executeResult{result} } func buildAddHeaderMaps(servers []dataplane.VirtualServer) []http.Map { diff --git a/internal/mode/static/nginx/config/servers.go b/internal/mode/static/nginx/config/servers.go index a80de123f7..32b8ef3371 100644 --- a/internal/mode/static/nginx/config/servers.go +++ b/internal/mode/static/nginx/config/servers.go @@ -38,10 +38,57 @@ var baseHeaders = []http.Header{ }, } -func executeServers(conf dataplane.Configuration) []byte { +func executeServers(conf dataplane.Configuration) []executeResult { servers := createServers(conf.HTTPServers, conf.SSLServers) - return execute(serversTemplate, servers) + serversResult := executeResult{ + dest: serversConfigFile, + data: execute(serversTemplate, servers), + } + + includeFileResults := createIncludeFileResults(servers) + + return append(includeFileResults, serversResult) +} + +func createIncludeFileResults(servers []http.Server) []executeResult { + uniqueIncludes := make(map[string][]byte) + + for _, s := range servers { + for _, inc := range s.Includes { + uniqueIncludes[inc.Filename] = inc.Content + } + + for _, l := range s.Locations { + for _, inc := range l.Includes { + uniqueIncludes[inc.Filename] = inc.Content + } + } + } + + results := make([]executeResult, 0, len(uniqueIncludes)) + + for filename, contents := range uniqueIncludes { + results = append(results, executeResult{ + dest: filename, + data: contents, + }) + } + + return results +} + +func createIncludes(customizations []*dataplane.Customization) []http.Include { + includes := make([]http.Include, 0, len(customizations)) + + for _, c := range customizations { + includes = append(includes, http.Include{ + Filename: fmt.Sprintf("%s/%s.conf", includesFolder, c.Identifier), + Content: c.Bytes, + }) + } + + return includes } func createServers(httpServers, sslServers []dataplane.VirtualServer) []http.Server { @@ -74,6 +121,7 @@ func createSSLServer(virtualServer dataplane.VirtualServer) http.Server { }, Locations: createLocations(virtualServer.PathRules, virtualServer.Port), Port: virtualServer.Port, + Includes: createIncludes(virtualServer.Customizations), } } @@ -89,6 +137,7 @@ func createServer(virtualServer dataplane.VirtualServer) http.Server { ServerName: virtualServer.Hostname, Locations: createLocations(virtualServer.PathRules, virtualServer.Port), Port: virtualServer.Port, + Includes: createIncludes(virtualServer.Customizations), } } @@ -115,10 +164,18 @@ func createLocations(pathRules []dataplane.PathRule, listenerPort int32) []http. for matchRuleIdx, r := range rule.MatchRules { buildLocations := extLocations + + includes := createIncludes(r.Customizations) + if len(rule.MatchRules) != 1 || !isPathOnlyMatch(r.Match) { intLocation, match := initializeInternalLocation(pathRuleIdx, matchRuleIdx, r.Match) + intLocation.Includes = includes buildLocations = []http.Location{intLocation} matches = append(matches, match) + } else { + for i := range extLocations { + extLocations[i].Includes = includes + } } buildLocations = updateLocationsForFilters(r.Filters, buildLocations, r, listenerPort, rule.Path) diff --git a/internal/mode/static/nginx/config/servers_template.go b/internal/mode/static/nginx/config/servers_template.go index dbf37575ae..743dd63f35 100644 --- a/internal/mode/static/nginx/config/servers_template.go +++ b/internal/mode/static/nginx/config/servers_template.go @@ -31,9 +31,17 @@ server { server_name {{ $s.ServerName }}; + {{ range $i := $s.Includes }} + include {{ $i.Filename }}; + {{- end -}} + {{ range $l := $s.Locations }} location {{ $l.Path }} { - {{- range $r := $l.Rewrites }} + {{- range $i := $l.Includes }} + include {{ $i.Filename }}; + {{- end -}} + + {{ range $r := $l.Rewrites }} rewrite {{ $r }}; {{- end }} diff --git a/internal/mode/static/nginx/config/split_clients.go b/internal/mode/static/nginx/config/split_clients.go index 61d63aaec0..10f7c43200 100644 --- a/internal/mode/static/nginx/config/split_clients.go +++ b/internal/mode/static/nginx/config/split_clients.go @@ -11,10 +11,13 @@ import ( var splitClientsTemplate = gotemplate.Must(gotemplate.New("split_clients").Parse(splitClientsTemplateText)) -func executeSplitClients(conf dataplane.Configuration) []byte { +func executeSplitClients(conf dataplane.Configuration) []executeResult { splitClients := createSplitClients(conf.BackendGroups) - - return execute(splitClientsTemplate, splitClients) + result := executeResult{ + dest: splitClientsConfigFile, + data: execute(splitClientsTemplate, splitClients), + } + return []executeResult{result} } func createSplitClients(backendGroups []dataplane.BackendGroup) []http.SplitClient { diff --git a/internal/mode/static/nginx/config/upstreams.go b/internal/mode/static/nginx/config/upstreams.go index 5d941890f3..580544a495 100644 --- a/internal/mode/static/nginx/config/upstreams.go +++ b/internal/mode/static/nginx/config/upstreams.go @@ -25,10 +25,14 @@ const ( invalidBackendZoneSize = "32k" ) -func (g GeneratorImpl) executeUpstreams(conf dataplane.Configuration) []byte { +func (g GeneratorImpl) executeUpstreams(conf dataplane.Configuration) []executeResult { upstreams := g.createUpstreams(conf.Upstreams) - return execute(upstreamsTemplate, upstreams) + result := executeResult{ + dest: upstreamsConfigFile, + data: execute(upstreamsTemplate, upstreams), + } + return []executeResult{result} } func (g GeneratorImpl) createUpstreams(upstreams []dataplane.Upstream) []http.Upstream { diff --git a/internal/mode/static/policies/clientsettings/generator.go b/internal/mode/static/policies/clientsettings/generator.go new file mode 100644 index 0000000000..8b9e6beacf --- /dev/null +++ b/internal/mode/static/policies/clientsettings/generator.go @@ -0,0 +1,55 @@ +package clientsettings + +import ( + "bytes" + "fmt" + "text/template" + + ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" +) + +// NewClientSettingsGeneratorFunc returns a function that generates configuration as []byte for a ClientSettingsPolicy. +func NewClientSettingsGeneratorFunc() func(policy policies.Policy) []byte { + return func(policy policies.Policy) []byte { + csp, ok := policy.(*ngfAPI.ClientSettingsPolicy) + if !ok { + panic(fmt.Sprintf("expected ClientSettingsPolicy, got: %T", policy)) + } + + tmpl := template.Must(template.New("client settings policy").Parse(clientSettingsTemplate)) + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, csp.Spec); err != nil { + panic(fmt.Errorf("failed to execute template for client settings policy: %w", err)) + } + + return buf.Bytes() + } +} + +var clientSettingsTemplate = ` +{{- if .Body }} + {{- if .Body.MaxSize }} +client_max_body_size {{ .Body.MaxSize }}; + {{- end }} + {{- if .Body.Timeout }} +client_body_timeout {{ .Body.Timeout }}; + {{- end }} +{{- end }} +{{- if .KeepAlive }} + {{- if .KeepAlive.Requests }} +keepalive_requests {{ .KeepAlive.Requests }}; + {{- end }} + {{- if .KeepAlive.Time }} +keepalive_time {{ .KeepAlive.Time }}; + {{- end }} + {{- if .KeepAlive.Timeout }} + {{- if and .KeepAlive.Timeout.Server .KeepAlive.Timeout.Header }} +keepalive_timeout {{ .KeepAlive.Timeout.Server }} {{ .KeepAlive.Timeout.Header }}; + {{- else if .KeepAlive.Timeout.Server }} +keepalive_timeout {{ .KeepAlive.Timeout.Server }}; + {{- end }} + {{- end }} +{{- end }} +` diff --git a/internal/mode/static/policies/clientsettings/validator.go b/internal/mode/static/policies/clientsettings/validator.go new file mode 100644 index 0000000000..015d1da552 --- /dev/null +++ b/internal/mode/static/policies/clientsettings/validator.go @@ -0,0 +1,180 @@ +package clientsettings + +import ( + "fmt" + "slices" + + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/apis/v1alpha2" + + ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" +) + +// Validator validates a ClientSettingsPolicy. +// Implements policies.Validator interface. +type Validator struct{} + +// Validate validates the spec of a ClientSettingsPolicy. +func (c Validator) Validate(policy policies.Policy) error { + csp, ok := policy.(*ngfAPI.ClientSettingsPolicy) + if !ok { + panic(fmt.Sprintf("expected ClientSettingsPolicy, got: %T", policy)) + } + + if err := validateTargetRef(csp.Spec.TargetRef, csp.Namespace); err != nil { + return err + } + + return validateSettings(csp.Spec) +} + +// Conflicts returns true if the two ClientSettingsPolicies conflict. +func (c Validator) Conflicts(polA, polB policies.Policy) bool { + a, okA := polA.(*ngfAPI.ClientSettingsPolicy) + b, okB := polB.(*ngfAPI.ClientSettingsPolicy) + + if !okA || !okB { + panic(fmt.Sprintf("expected ClientSettingsPolicy, got: %T", polA)) + } + + return conflicts(a.Spec, b.Spec) +} + +func conflicts(a, b ngfAPI.ClientSettingsPolicySpec) bool { + if a.Body != nil && b.Body != nil { + if a.Body.Timeout != nil && b.Body.Timeout != nil { + return true + } + + if a.Body.MaxSize != nil && b.Body.MaxSize != nil { + return true + } + } + + if a.KeepAlive != nil && b.KeepAlive != nil { + if a.KeepAlive.Requests != nil && b.KeepAlive.Requests != nil { + return true + } + + if a.KeepAlive.Time != nil && b.KeepAlive.Time != nil { + return true + } + + if a.KeepAlive.Timeout != nil && b.KeepAlive.Timeout != nil { + return true + } + } + + return false +} + +func validateTargetRef(ref v1alpha2.PolicyTargetReference, policyNs string) error { + basePath := field.NewPath("spec").Child("targetRef") + + if ref.Namespace != nil && string(*ref.Namespace) != policyNs { + path := basePath.Child("namespace") + + return field.Invalid(path, *ref.Namespace, "targetRef must be in the same namespace as the policy") + } + + if ref.Group != gatewayv1.GroupName { + path := basePath.Child("group") + + return field.Invalid( + path, + ref.Group, + fmt.Sprintf("unsupported targetRef Group %q; must be %s", ref.Group, gatewayv1.GroupName), + ) + } + + kinds := []gatewayv1.Kind{"HTTPRoute", "Gateway"} + + if !slices.Contains(kinds, ref.Kind) { + path := basePath.Child("kind") + + return field.Invalid( + path, + ref.Kind, + fmt.Sprintf("unsupported targetRef Kind %q; Kind must be one of: %v", ref.Kind, kinds), + ) + } + + return nil +} + +func validateSettings(spec ngfAPI.ClientSettingsPolicySpec) error { + var allErrs field.ErrorList + fieldPath := field.NewPath("spec") + + if spec.Body != nil { + if err := policies.ValidateDuration(spec.Body.Timeout); err != nil { + path := fieldPath.Child("body").Child("timeout") + + allErrs = append(allErrs, field.Invalid(path, *spec.Body.Timeout, err.Error())) + } + + if err := policies.ValidateSize(spec.Body.MaxSize); err != nil { + path := fieldPath.Child("body").Child("size") + + allErrs = append(allErrs, field.Invalid(path, *spec.Body.MaxSize, err.Error())) + } + } + + if spec.KeepAlive != nil { + if spec.KeepAlive.Requests != nil { + requests := *spec.KeepAlive.Requests + if requests < 0 { + path := fieldPath.Child("keepAlive").Child("requests") + + allErrs = append( + allErrs, + field.Invalid(path, *spec.KeepAlive.Requests, "requests is invalid: must be positive"), + ) + } + } + + if err := policies.ValidateDuration(spec.KeepAlive.Time); err != nil { + path := fieldPath.Child("body").Child("keepAlive").Child("time") + + allErrs = append(allErrs, field.Invalid(path, *spec.KeepAlive.Time, err.Error())) + } + + if spec.KeepAlive.Timeout != nil { + timeout := spec.KeepAlive.Timeout + + if err := policies.ValidateDuration(timeout.Server); err != nil { + path := fieldPath.Child("keepAlive").Child("timeout").Child("server") + + allErrs = append( + allErrs, + field.Invalid(path, *spec.KeepAlive.Timeout.Server, err.Error()), + ) + } + + if err := policies.ValidateDuration(timeout.Header); err != nil { + path := fieldPath.Child("keepAlive").Child("timeout").Child("header") + + allErrs = append( + allErrs, + field.Invalid(path, *spec.KeepAlive.Timeout.Header, err.Error()), + ) + } + + if spec.KeepAlive.Timeout.Header != nil && spec.KeepAlive.Timeout.Server == nil { + path := fieldPath.Child("keepAlive").Child("timeout") + + allErrs = append( + allErrs, + field.Invalid( + path, + nil, + "server timeout must be set if header timeout is set", + ), + ) + } + } + } + return allErrs.ToAggregate() +} diff --git a/internal/mode/static/policies/common_validation.go b/internal/mode/static/policies/common_validation.go new file mode 100644 index 0000000000..0710fe4d3c --- /dev/null +++ b/internal/mode/static/policies/common_validation.go @@ -0,0 +1,27 @@ +package policies + +import ( + "regexp" + + ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" +) + +func ValidateSize(s *ngfAPI.Size) error { + if s == nil { + return nil + } + + _, err := regexp.MatchString(`^\d{1,4}(m|g|k)+$`, string(*s)) + + return err +} + +func ValidateDuration(d *ngfAPI.Duration) error { + if d == nil { + return nil + } + + _, err := regexp.MatchString(`^\d{1,4}(ms|s)?$`, string(*d)) + + return err +} diff --git a/internal/mode/static/policies/manager.go b/internal/mode/static/policies/manager.go new file mode 100644 index 0000000000..a35a559724 --- /dev/null +++ b/internal/mode/static/policies/manager.go @@ -0,0 +1,92 @@ +package policies + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// GenerateFunc generates config as []byte for an NGF Policy. +type GenerateFunc func(policy Policy) []byte + +// Validator validates an NGF Policy. +type Validator interface { + // Validate validates an NGF Policy. + Validate(policy Policy) error + // Conflicts returns true if the two Policies conflict. + Conflicts(a, b Policy) bool +} + +// Manager manages the validators and generators for NGF Policies. +type Manager struct { + validators map[schema.GroupVersionKind]Validator + generators map[schema.GroupVersionKind]GenerateFunc + mustExtractGVK func(client.Object) schema.GroupVersionKind +} + +// ManagerConfig contains the config to register a Policy with the Manager. +type ManagerConfig struct { + // GVK is the GroupVersionKind of the Policy. + GVK schema.GroupVersionKind + // Validator is the Validator for the Policy. + Validator Validator + // Generator is the GenerateFunc for the Policy. + Generator GenerateFunc +} + +// NewManager returns a new Manager. +// Implements dataplane.PolicyConfigGenerator and validation.PolicyValidator. +func NewManager( + mustExtractGVK func(client.Object) schema.GroupVersionKind, + configs ...ManagerConfig, +) *Manager { + v := &Manager{ + validators: make(map[schema.GroupVersionKind]Validator), + generators: make(map[schema.GroupVersionKind]GenerateFunc), + mustExtractGVK: mustExtractGVK, + } + + for _, cfg := range configs { + v.validators[cfg.GVK] = cfg.Validator + v.generators[cfg.GVK] = cfg.Generator + } + + return v +} + +// Generate generates config for the policy as a byte array. +func (m *Manager) Generate(policy Policy) []byte { + gvk := m.mustExtractGVK(policy) + + generate, ok := m.generators[gvk] + if !ok { + panic(fmt.Sprintf("no generate function registered for policy %T", policy)) + } + + return generate(policy) +} + +// Validate validates the policy. +func (m *Manager) Validate(policy Policy) error { + gvk := m.mustExtractGVK(policy) + + validator, ok := m.validators[gvk] + if !ok { + panic(fmt.Sprintf("no validator registered for policy %T", policy)) + } + + return validator.Validate(policy) +} + +// Conflicts returns true if the policies conflict. +func (m *Manager) Conflicts(polA, polB Policy) bool { + gvk := m.mustExtractGVK(polA) + + validator, ok := m.validators[gvk] + if !ok { + panic(fmt.Sprintf("no validator registered for policy %T", polA)) + } + + return validator.Conflicts(polA, polB) +} diff --git a/internal/mode/static/policies/policy.go b/internal/mode/static/policies/policy.go new file mode 100644 index 0000000000..2ca320f013 --- /dev/null +++ b/internal/mode/static/policies/policy.go @@ -0,0 +1,14 @@ +package policies + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +// Policy is an extension of client.Object. It adds methods that are common among all NGF Policies. +type Policy interface { + GetTargetRef() v1alpha2.PolicyTargetReference + GetPolicyStatus() v1alpha2.PolicyStatus + SetPolicyStatus(status v1alpha2.PolicyStatus) + client.Object +} diff --git a/internal/mode/static/sort/sort.go b/internal/mode/static/sort/sort.go index ee9db1b24b..80f59e8fae 100644 --- a/internal/mode/static/sort/sort.go +++ b/internal/mode/static/sort/sort.go @@ -1,6 +1,9 @@ package sort -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) // LessObjectMeta compares two ObjectMetas according to the Gateway API conflict resolution guidelines. // See https://gateway-api.sigs.k8s.io/concepts/guidelines/?h=conflict#conflicts @@ -14,3 +17,20 @@ func LessObjectMeta(meta1 *metav1.ObjectMeta, meta2 *metav1.ObjectMeta) bool { return meta1.CreationTimestamp.Before(&meta2.CreationTimestamp) } + +// ClientObject compares two client.Objects and returns true if the first object was created first. +// If the objects were created at the same time, +// it returns true if the first object's name appears first in alphabetical order. +func ClientObject(obj1 client.Object, obj2 client.Object) bool { + create1 := obj1.GetCreationTimestamp() + create2 := obj2.GetCreationTimestamp() + + if create1.Time.Equal(create2.Time) { + if obj1.GetNamespace() == obj2.GetNamespace() { + return obj1.GetName() < obj2.GetName() + } + return obj1.GetNamespace() < obj2.GetNamespace() + } + + return create1.Time.Before(create2.Time) +} diff --git a/internal/mode/static/state/change_processor.go b/internal/mode/static/state/change_processor.go index db035746c4..444edb1c08 100644 --- a/internal/mode/static/state/change_processor.go +++ b/internal/mode/static/state/change_processor.go @@ -19,7 +19,9 @@ import ( "sigs.k8s.io/gateway-api/apis/v1alpha2" "sigs.k8s.io/gateway-api/apis/v1beta1" + ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" "github.com/nginxinc/nginx-gateway-fabric/internal/framework/gatewayclass" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/graph" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/validation" ) @@ -87,6 +89,7 @@ type ChangeProcessorImpl struct { updater Updater // getAndResetClusterStateChanged tells if and how the cluster state has changed. getAndResetClusterStateChanged func() ChangeType + extractGVK extractGVKFunc cfg ChangeProcessorConfig lock sync.Mutex @@ -105,6 +108,7 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl { CRDMetadata: make(map[types.NamespacedName]*metav1.PartialObjectMetadata), BackendTLSPolicies: make(map[types.NamespacedName]*v1alpha2.BackendTLSPolicy), ConfigMaps: make(map[types.NamespacedName]*apiv1.ConfigMap), + NGFPolicies: make(map[graph.PolicyKey]policies.Policy), } extractGVK := func(obj client.Object) schema.GroupVersionKind { @@ -118,12 +122,27 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl { processor := &ChangeProcessorImpl{ cfg: cfg, clusterState: clusterStore, + extractGVK: extractGVK, } isReferenced := func(obj client.Object, nsname types.NamespacedName) bool { return processor.latestGraph != nil && processor.latestGraph.IsReferenced(obj, nsname) } + isNGFPolicyRelevant := func(obj client.Object, nsname types.NamespacedName) bool { + pol, ok := obj.(policies.Policy) + if !ok { + return false + } + + gvk := extractGVK(obj) + + return processor.latestGraph != nil && processor.latestGraph.IsNGFPolicyRelevant(pol, gvk, nsname) + } + + // Use this object store for all NGF policies + commonPolicyObjectStore := newNGFPolicyObjectStore(clusterStore.NGFPolicies, extractGVK) + trackingUpdater := newChangeTrackingUpdater( extractGVK, []changeTrackingUpdaterObjectTypeCfg{ @@ -182,6 +201,11 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl { store: newObjectStoreMapAdapter(clusterStore.CRDMetadata), predicate: annotationChangedPredicate{annotation: gatewayclass.BundleVersionAnnotation}, }, + { + gvk: extractGVK(&ngfAPI.ClientSettingsPolicy{}), + store: commonPolicyObjectStore, + predicate: funcPredicate{stateChanged: isNGFPolicyRelevant}, + }, }, ) diff --git a/internal/mode/static/state/conditions/conditions.go b/internal/mode/static/state/conditions/conditions.go index 894fb3596c..eaad7b44bf 100644 --- a/internal/mode/static/state/conditions/conditions.go +++ b/internal/mode/static/state/conditions/conditions.go @@ -575,3 +575,46 @@ func NewBackendTLSPolicyInvalid(msg string) conditions.Condition { Message: msg, } } + +// NewPolicyAccepted returns a Condition that indicates that the Policy is accepted. +func NewPolicyAccepted() conditions.Condition { + return conditions.Condition{ + Type: string(v1alpha2.PolicyConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(v1alpha2.PolicyReasonAccepted), + Message: "Policy is accepted", + } +} + +// NewPolicyInvalid returns a Condition that indicates that the Policy is not accepted because it is semantically or +// syntactically invalid. +func NewPolicyInvalid(msg string) conditions.Condition { + return conditions.Condition{ + Type: string(v1alpha2.PolicyConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1alpha2.PolicyReasonInvalid), + Message: msg, + } +} + +// NewPolicyConflicted returns a Condition that indicates that the Policy is not accepted because it conflicts with +// another Policy and a merge is not possible. +func NewPolicyConflicted(msg string) conditions.Condition { + return conditions.Condition{ + Type: string(v1alpha2.PolicyConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1alpha2.PolicyReasonConflicted), + Message: msg, + } +} + +// NewPolicyTargetNotFound returns a Condition that indicates that the Policy is not accepted because the target +// resource does not exist or can not be attached to. +func NewPolicyTargetNotFound(msg string) conditions.Condition { + return conditions.Condition{ + Type: string(v1alpha2.PolicyConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1alpha2.PolicyReasonTargetNotFound), + Message: msg, + } +} diff --git a/internal/mode/static/state/dataplane/configuration.go b/internal/mode/static/state/dataplane/configuration.go index 25ed7e217a..9b2c02cd73 100644 --- a/internal/mode/static/state/dataplane/configuration.go +++ b/internal/mode/static/state/dataplane/configuration.go @@ -11,6 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" v1 "sigs.k8s.io/gateway-api/apis/v1" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/graph" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/resolver" ) @@ -20,11 +21,17 @@ const ( alpineSSLRootCAPath = "/etc/ssl/cert.pem" ) +// PolicyConfigGenerator generates a slice of bytes containing the configuration from a policies.Policy. +type PolicyConfigGenerator interface { + Generate(policy policies.Policy) []byte +} + // BuildConfiguration builds the Configuration from the Graph. func BuildConfiguration( ctx context.Context, g *graph.Graph, resolver resolver.ServiceResolver, + generator PolicyConfigGenerator, configVersion int, ) Configuration { if g.GatewayClass == nil || !g.GatewayClass.Valid { @@ -36,7 +43,7 @@ func BuildConfiguration( } upstreams := buildUpstreams(ctx, g.Gateway.Listeners, resolver) - httpServers, sslServers := buildServers(g.Gateway.Listeners) + httpServers, sslServers := buildServers(g.Gateway, generator) backendGroups := buildBackendGroups(append(httpServers, sslServers...)) keyPairs := buildSSLKeyPairs(g.ReferencedSecrets, g.Gateway.Listeners) certBundles := buildCertBundles(g.ReferencedCaCertConfigMaps, backendGroups) @@ -194,17 +201,17 @@ func convertBackendTLS(btp *graph.BackendTLSPolicy) *VerifyTLS { return verify } -func buildServers(listeners []*graph.Listener) (http, ssl []VirtualServer) { +func buildServers(gw *graph.Gateway, generator PolicyConfigGenerator) (http, ssl []VirtualServer) { rulesForProtocol := map[v1.ProtocolType]portPathRules{ v1.HTTPProtocolType: make(portPathRules), v1.HTTPSProtocolType: make(portPathRules), } - for _, l := range listeners { + for _, l := range gw.Listeners { if l.Valid { rules := rulesForProtocol[l.Source.Protocol][l.Source.Port] if rules == nil { - rules = newHostPathRules() + rules = newHostPathRules(generator) rulesForProtocol[l.Source.Protocol][l.Source.Port] = rules } @@ -215,7 +222,34 @@ func buildServers(listeners []*graph.Listener) (http, ssl []VirtualServer) { httpRules := rulesForProtocol[v1.HTTPProtocolType] sslRules := rulesForProtocol[v1.HTTPSProtocolType] - return httpRules.buildServers(), sslRules.buildServers() + httpServers, sslServers := httpRules.buildServers(), sslRules.buildServers() + + customizations := make([]*Customization, 0, len(gw.Policies)) + for _, policy := range gw.Policies { + customizations = append(customizations, createCustomization(policy, generator)) + } + + for i := range httpServers { + httpServers[i].Customizations = customizations + } + + for i := range sslServers { + sslServers[i].Customizations = customizations + } + + return httpServers, sslServers +} + +func createCustomization(policy *graph.Policy, generator PolicyConfigGenerator) *Customization { + return &Customization{ + Bytes: generator.Generate(policy.Source), + Identifier: fmt.Sprintf( + "%s_%s_%s", + policy.Source.GetObjectKind().GroupVersionKind().Kind, + policy.Source.GetNamespace(), + policy.Source.GetName(), + ), + } } // portPathRules keeps track of hostPathRules per port @@ -247,13 +281,15 @@ type hostPathRules struct { httpsListeners []*graph.Listener listenersExist bool port int32 + generator PolicyConfigGenerator } -func newHostPathRules() *hostPathRules { +func newHostPathRules(generator PolicyConfigGenerator) *hostPathRules { return &hostPathRules{ rulesPerHost: make(map[string]map[pathAndType]PathRule), listenersForHost: make(map[string]*graph.Listener), httpsListeners: make([]*graph.Listener, 0), + generator: generator, } } @@ -311,6 +347,11 @@ func (hpr *hostPathRules) upsertRoute(route *graph.Route, listener *graph.Listen } } + customizations := make([]*Customization, 0, len(route.Policies)) + for _, p := range route.Policies { + customizations = append(customizations, createCustomization(p, hpr.generator)) + } + for _, h := range hostnames { for _, m := range rule.Matches { path := getPath(m.Path) @@ -332,10 +373,11 @@ func (hpr *hostPathRules) upsertRoute(route *graph.Route, listener *graph.Listen routeNsName := client.ObjectKeyFromObject(route.Source) rule.MatchRules = append(rule.MatchRules, MatchRule{ - Source: &om, - BackendGroup: newBackendGroup(route.Rules[i].BackendRefs, routeNsName, i), - Filters: filters, - Match: convertMatch(m), + Source: &om, + BackendGroup: newBackendGroup(route.Rules[i].BackendRefs, routeNsName, i), + Filters: filters, + Match: convertMatch(m), + Customizations: customizations, }) hpr.rulesPerHost[h][key] = rule diff --git a/internal/mode/static/state/dataplane/types.go b/internal/mode/static/state/dataplane/types.go index da1b00afaa..da3e4918b1 100644 --- a/internal/mode/static/state/dataplane/types.go +++ b/internal/mode/static/state/dataplane/types.go @@ -68,6 +68,16 @@ type VirtualServer struct { IsDefault bool // Port is the port of the server. Port int32 + // Customizations contain custom configuration for the VirtualServer. + Customizations []*Customization +} + +// Customization holds custom configuration. +type Customization struct { + // Bytes contains the customization as a byte array. + Bytes []byte + // Identifier is a unique ID for the customization. + Identifier string } // Upstream is a pool of endpoints to be load balanced. @@ -198,6 +208,8 @@ type MatchRule struct { Match Match // BackendGroup is the group of Backends that the rule routes to. BackendGroup BackendGroup + // Customizations contain custom configuration for the MatchRule. + Customizations []*Customization } // Match represents a match for a routing rule which consist of matches against various HTTP request attributes. diff --git a/internal/mode/static/state/graph/gateway.go b/internal/mode/static/state/graph/gateway.go index 79f0ae9df4..579f24deb2 100644 --- a/internal/mode/static/state/graph/gateway.go +++ b/internal/mode/static/state/graph/gateway.go @@ -23,6 +23,8 @@ type Gateway struct { Conditions []conditions.Condition // Valid indicates whether the Gateway Spec is valid. Valid bool + // Policies hold the valid Policies attached to the Gateway. + Policies []*Policy } // processedGateways holds the resources that belong to NGF. diff --git a/internal/mode/static/state/graph/graph.go b/internal/mode/static/state/graph/graph.go index f6ee5598b9..deac9d7cf0 100644 --- a/internal/mode/static/state/graph/graph.go +++ b/internal/mode/static/state/graph/graph.go @@ -4,6 +4,7 @@ import ( v1 "k8s.io/api/core/v1" discoveryV1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -11,6 +12,7 @@ import ( "sigs.k8s.io/gateway-api/apis/v1beta1" "github.com/nginxinc/nginx-gateway-fabric/internal/framework/controller/index" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/validation" ) @@ -26,6 +28,7 @@ type ClusterState struct { CRDMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata BackendTLSPolicies map[types.NamespacedName]*v1alpha2.BackendTLSPolicy ConfigMaps map[types.NamespacedName]*v1.ConfigMap + NGFPolicies map[PolicyKey]policies.Policy } // Graph is a Graph-like representation of Gateway API resources. @@ -58,6 +61,8 @@ type Graph struct { ReferencedCaCertConfigMaps map[types.NamespacedName]*CaCertConfigMap // BackendTLSPolicies holds BackendTLSPolicy resources. BackendTLSPolicies map[types.NamespacedName]*BackendTLSPolicy + // NGFPolicies holds all NGF Policies. + NGFPolicies map[PolicyKey]*Policy } // ProtectedPorts are the ports that may not be configured by a listener with a descriptive name of each port. @@ -104,6 +109,53 @@ func (g *Graph) IsReferenced(resourceType client.Object, nsname types.Namespaced } } +// IsNGFPolicyRelevant returns whether the NGF Policy is a part of the Graph, or targets a resource in the Graph. +func (g *Graph) IsNGFPolicyRelevant( + policy policies.Policy, + gvk schema.GroupVersionKind, + nsname types.NamespacedName, +) bool { + key := PolicyKey{ + NsName: nsname, + GVK: gvk, + } + + if _, exists := g.NGFPolicies[key]; exists { + return true + } + + if policy == nil { + return false + } + + ref := policy.GetTargetRef() + + switch ref.Group { + case gatewayv1.GroupName: + return g.gatewayResourceExist(ref, policy.GetNamespace()) + default: + return false + } +} + +func (g *Graph) gatewayResourceExist(ref v1alpha2.PolicyTargetReference, policyNs string) bool { + refNsName := targetRefNsName(ref, policyNs) + + switch ref.Kind { + case "Gateway": + if g.Gateway == nil { + return false + } + + return gatewayExists(refNsName, g.Gateway.Source, g.IgnoredGateways) + case "HTTPRoute": + _, exists := g.Routes[refNsName] + return exists + default: + return false + } +} + // BuildGraph builds a Graph from a state. func BuildGraph( state ClusterState, @@ -126,6 +178,7 @@ func BuildGraph( processedGws := processGateways(state.Gateways, gcName) refGrantResolver := newReferenceGrantResolver(state.ReferenceGrants) + gw := buildGateway(processedGws.Winner, secretResolver, gc, refGrantResolver, protectedPorts) processedBackendTLSPolicies := processBackendTLSPolicies( @@ -143,6 +196,8 @@ func BuildGraph( referencedServices := buildReferencedServices(routes) + processedPolicies := processPolicies(state.NGFPolicies, validators.PolicyValidator, processedGws, routes) + g := &Graph{ GatewayClass: gc, Gateway: gw, @@ -154,7 +209,38 @@ func BuildGraph( ReferencedServices: referencedServices, ReferencedCaCertConfigMaps: configMapResolver.getResolvedConfigMaps(), BackendTLSPolicies: processedBackendTLSPolicies, + NGFPolicies: processedPolicies, } + g.attachPolicies() + return g } + +func targetRefNsName(ref v1alpha2.PolicyTargetReference, objNs string) types.NamespacedName { + ns := objNs + + if ref.Namespace != nil { + ns = string(*ref.Namespace) + } + + return types.NamespacedName{Name: string(ref.Name), Namespace: ns} +} + +func gatewayExists( + gwNsName types.NamespacedName, + winner *gatewayv1.Gateway, + ignored map[types.NamespacedName]*gatewayv1.Gateway, +) bool { + if winner == nil { + return false + } + + if client.ObjectKeyFromObject(winner) == gwNsName { + return true + } + + _, exists := ignored[gwNsName] + + return exists +} diff --git a/internal/mode/static/state/graph/httproute.go b/internal/mode/static/state/graph/httproute.go index d16e833f00..3bcf46bb8a 100644 --- a/internal/mode/static/state/graph/httproute.go +++ b/internal/mode/static/state/graph/httproute.go @@ -37,6 +37,8 @@ type ParentRef struct { Attachment *ParentRefAttachmentStatus // Gateway is the NamespacedName of the referenced Gateway Gateway types.NamespacedName + // SectionName is the SectionName of the referenced Gateway + SectionName string // Idx is the index of the corresponding ParentReference in the HTTPRoute. Idx int } @@ -70,6 +72,8 @@ type Route struct { // Attachable tells if the Route can be attached to any of the Gateways. // Route can be invalid but still attachable. Attachable bool + // Policies hold the valid Policies attached to the Route. + Policies []*Policy } // buildRoutesForGateways builds routes from HTTPRoutes that reference any of the specified Gateways. @@ -129,8 +133,9 @@ func buildSectionNameRefs( uniqueSectionsPerGateway[k] = struct{}{} sectionNameRefs = append(sectionNameRefs, ParentRef{ - Idx: i, - Gateway: gw, + Idx: i, + Gateway: gw, + SectionName: sectionName, }) } diff --git a/internal/mode/static/state/graph/policies.go b/internal/mode/static/state/graph/policies.go new file mode 100644 index 0000000000..e4eb89510f --- /dev/null +++ b/internal/mode/static/state/graph/policies.go @@ -0,0 +1,313 @@ +package graph + +import ( + "fmt" + "sort" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + v1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/nginxinc/nginx-gateway-fabric/internal/framework/conditions" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" + ngfsort "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/sort" + staticConds "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/conditions" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/validation" +) + +// Policy represents an NGF Policy. +type Policy struct { + // Source is the NGF Policy object. + Source policies.Policy + // TargetRef is the TargetRef of the Policy. + TargetRef PolicyTargetRef + // Valid indicates whether the Policy is semantically and syntactically valid. + Valid bool + // Conditions contains the conditions of the Policy. Conditions will be nil if the Policy is valid. + Conditions []conditions.Condition + // Ancestors is a list of the ancestors of the Policies. + Ancestors []PolicyAncestor +} + +// PolicyAncestor represents an ancestor of a Policy. +type PolicyAncestor struct { + // Ancestor is the ancestor object. + Ancestor PolicyAncestorRef + // Conditions contains the list of conditions of the Policy in relation to the ancestor. + Conditions []conditions.Condition +} + +// PolicyAncestorRef contains the identifying information of the ancestor. +type PolicyAncestorRef struct { + // Kind is the Kind of the object. + Kind v1.Kind + // Group is the Group of the object. + Group v1.Group + // Nsname is the NamespacedName of the object. + Nsname types.NamespacedName + // SectionName is the SectionName of the object. For example, a listener name. This may be empty. + SectionName string +} + +// PolicyTargetRef represents the object that the Policy is targeting. +type PolicyTargetRef struct { + // Kind is the Kind of the object. + Kind v1.Kind + // Group is the Group of the object. + Group v1.Group + // Nsname is the NamespacedName of the object. + Nsname types.NamespacedName +} + +// PolicyKey is a unique identifier for an NGF Policy. +type PolicyKey struct { + // Nsname is the NamespacedName of the Policy. + NsName types.NamespacedName + // GVK is the GroupVersionKind of the Policy. + GVK schema.GroupVersionKind +} + +const ( + gatewayGroupKind = v1.GroupName + "/" + "Gateway" + hrGroupKind = v1.GroupName + "/" + "HTTPRoute" +) + +// attachPolicies attaches the graph's processed policies to the resources they target. It modifies the graph in place. +func (g *Graph) attachPolicies() { + if g.Gateway == nil { + return + } + + for _, policy := range g.NGFPolicies { + ref := policy.TargetRef + + switch ref.Kind { + case "Gateway": + ancestor, attached := attachPolicyToGateway(policy, g.Gateway, g.IgnoredGateways) + if attached { + if g.Gateway.Policies == nil { + g.Gateway.Policies = make([]*Policy, 0, len(g.NGFPolicies)) + } + + g.Gateway.Policies = append(g.Gateway.Policies, policy) + } + + policy.Ancestors = []PolicyAncestor{ancestor} + case "HTTPRoute": + route, exists := g.Routes[ref.Nsname] + if !exists { + return + } + + ancestors, attached := attachPolicyToRoute(policy, route) + + if attached { + if route.Policies == nil { + route.Policies = make([]*Policy, 0, len(g.NGFPolicies)) + } + + route.Policies = append(route.Policies, policy) + } + + policy.Ancestors = ancestors + } + } +} + +func attachPolicyToRoute(policy *Policy, route *Route) (ancestors []PolicyAncestor, attached bool) { + ancestors = make([]PolicyAncestor, 0, len(route.ParentRefs)) + + if len(route.ParentRefs) == 0 { + // this is an edge case that only happens when there are duplicate section names in the route + routeNsName := types.NamespacedName{Namespace: route.Source.Namespace, Name: route.Source.Name} + ancestors = append(ancestors, PolicyAncestor{ + Ancestor: PolicyAncestorRef{ + Kind: "HTTPRoute", + Group: v1.GroupName, + Nsname: routeNsName, + }, + Conditions: []conditions.Condition{staticConds.NewPolicyTargetNotFound("TargetRef is invalid")}, + }) + + return ancestors, false + } + + for _, pr := range route.ParentRefs { + ancestor := PolicyAncestor{ + Ancestor: PolicyAncestorRef{ + Kind: "Gateway", + Group: v1.GroupName, + Nsname: pr.Gateway, + SectionName: pr.SectionName, + }, + Conditions: make([]conditions.Condition, 0, 1), + } + + if !parentRefAttached(route, pr) { + ancestor.Conditions = append( + ancestor.Conditions, + staticConds.NewPolicyTargetNotFound("TargetRef is invalid"), + ) + } else if policy.Valid { + ancestor.Conditions = append(ancestor.Conditions, staticConds.NewPolicyAccepted()) + attached = true + } + + ancestors = append(ancestors, ancestor) + } + + return ancestors, attached +} + +func parentRefAttached(route *Route, parent ParentRef) bool { + return route.Valid && parent.Attachment != nil && parent.Attachment.Attached +} + +func attachPolicyToGateway( + policy *Policy, + gw *Gateway, + ignoredGateways map[types.NamespacedName]*v1.Gateway, +) (ancestor PolicyAncestor, attached bool) { + ref := policy.TargetRef + + _, ignored := ignoredGateways[ref.Nsname] + + if !ignored && ref.Nsname != client.ObjectKeyFromObject(gw.Source) { + return PolicyAncestor{}, false + } + + ancestor = PolicyAncestor{ + Ancestor: PolicyAncestorRef{ + Kind: "Gateway", + Group: v1.GroupName, + Nsname: ref.Nsname, + }, + Conditions: make([]conditions.Condition, 0), + } + + if ignored { + ancestor.Conditions = append(ancestor.Conditions, staticConds.NewPolicyTargetNotFound("TargetRef is ignored")) + + return ancestor, false + } + + if !gw.Valid { + ancestor.Conditions = append(ancestor.Conditions, staticConds.NewPolicyTargetNotFound("TargetRef is invalid")) + + return ancestor, false + } + + if !policy.Valid { + return ancestor, false + } + + ancestor.Conditions = append(ancestor.Conditions, staticConds.NewPolicyAccepted()) + + return ancestor, true +} + +func processPolicies( + policies map[PolicyKey]policies.Policy, + validator validation.PolicyValidator, + gateways processedGateways, + routes map[types.NamespacedName]*Route, +) map[PolicyKey]*Policy { + if len(policies) == 0 || gateways.Winner == nil { + return nil + } + + processedPolicies := make(map[PolicyKey]*Policy) + + for key, policy := range policies { + ref := policy.GetTargetRef() + refNsName := targetRefNsName(ref, policy.GetNamespace()) + + refGroupKind := fmt.Sprintf("%s/%s", ref.Group, ref.Kind) + + switch refGroupKind { + case gatewayGroupKind: + if !gatewayExists(refNsName, gateways.Winner, gateways.Ignored) { + continue + } + case hrGroupKind: + if _, exists := routes[refNsName]; !exists { + continue + } + } + + conds := make([]conditions.Condition, 0, 2) + + if err := validator.Validate(policy); err != nil { + conds = append(conds, staticConds.NewPolicyInvalid(err.Error())) + } + + processedPolicies[key] = &Policy{ + Source: policy, + Valid: len(conds) == 0, + Conditions: conds, + TargetRef: PolicyTargetRef{ + Kind: ref.Kind, + Group: ref.Group, + Nsname: refNsName, + }, + } + } + + markConflictedPolicies(processedPolicies, validator) + + return processedPolicies +} + +// markConflictedPolicies marks policies that conflict with a policy of greater precedence as invalid. +// Policies are sorted by timestamp and then alphabetically. +func markConflictedPolicies(policies map[PolicyKey]*Policy, validator validation.PolicyValidator) { + type key struct { + policyGVK schema.GroupVersionKind + PolicyTargetRef + } + + possibles := make(map[key][]*Policy) + + for pk, p := range policies { + if p.Valid { + ak := key{ + PolicyTargetRef: p.TargetRef, + policyGVK: pk.GVK, + } + if possibles[ak] == nil { + possibles[ak] = make([]*Policy, 0) + } + possibles[ak] = append(possibles[ak], p) + } + } + + for _, policyList := range possibles { + if len(policyList) > 1 { + sort.SliceStable( + policyList, func(i, j int) bool { + return ngfsort.ClientObject(policyList[i].Source, policyList[j].Source) + }, + ) + + for i := range policyList { + if !policyList[i].Valid { + continue + } + + for j := i + 1; j < len(policyList); j++ { + if validator.Conflicts(policyList[i].Source, policyList[j].Source) { + conflicted := policyList[j] + conflicted.Valid = false + conflicted.Conditions = append(conflicted.Conditions, staticConds.NewPolicyConflicted( + fmt.Sprintf( + "Conflicts with another %s", + conflicted.Source.GetObjectKind().GroupVersionKind().Kind, + ), + )) + } + } + } + } + } +} diff --git a/internal/mode/static/state/store.go b/internal/mode/static/state/store.go index 02a2585580..932a371448 100644 --- a/internal/mode/static/state/store.go +++ b/internal/mode/static/state/store.go @@ -7,6 +7,9 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/state/graph" ) // Updater updates the cluster state. @@ -17,9 +20,59 @@ type Updater interface { // objectStore is a store of client.Object type objectStore interface { - get(nsname types.NamespacedName) client.Object + get(objType client.Object, nsname types.NamespacedName) client.Object upsert(obj client.Object) - delete(nsname types.NamespacedName) + delete(objType client.Object, nsname types.NamespacedName) +} + +// ngfPolicyObjectStore is a store of policies.Policy. +// A single store should be used to store all types of policies.Policy. +type ngfPolicyObjectStore struct { + policies map[graph.PolicyKey]policies.Policy + extractGVKFunc extractGVKFunc +} + +// newNGFPolicyObjectStore returns a new ngfPolicyObjectStore. +func newNGFPolicyObjectStore( + policies map[graph.PolicyKey]policies.Policy, + gvkFunc extractGVKFunc, +) *ngfPolicyObjectStore { + return &ngfPolicyObjectStore{ + policies: policies, + extractGVKFunc: gvkFunc, + } +} + +func (p *ngfPolicyObjectStore) get(objType client.Object, nsname types.NamespacedName) client.Object { + key := graph.PolicyKey{ + NsName: nsname, + GVK: p.extractGVKFunc(objType), + } + + return p.policies[key] +} + +func (p *ngfPolicyObjectStore) upsert(obj client.Object) { + key := graph.PolicyKey{ + NsName: client.ObjectKeyFromObject(obj), + GVK: p.extractGVKFunc(obj), + } + + pol, ok := obj.(policies.Policy) + if !ok { + panic(fmt.Sprintf("expected NGF Policy, got %T", obj)) + } + + p.policies[key] = pol +} + +func (p *ngfPolicyObjectStore) delete(objType client.Object, nsname types.NamespacedName) { + key := graph.PolicyKey{ + NsName: nsname, + GVK: p.extractGVKFunc(objType), + } + + delete(p.policies, key) } // objectStoreMapAdapter wraps maps of types.NamespacedName to Kubernetes resources @@ -34,7 +87,7 @@ func newObjectStoreMapAdapter[T client.Object](objects map[types.NamespacedName] } } -func (m *objectStoreMapAdapter[T]) get(nsname types.NamespacedName) client.Object { +func (m *objectStoreMapAdapter[T]) get(_ client.Object, nsname types.NamespacedName) client.Object { obj, exist := m.objects[nsname] if !exist { return nil @@ -51,7 +104,7 @@ func (m *objectStoreMapAdapter[T]) upsert(obj client.Object) { m.objects[client.ObjectKeyFromObject(obj)] = t } -func (m *objectStoreMapAdapter[T]) delete(nsname types.NamespacedName) { +func (m *objectStoreMapAdapter[T]) delete(_ client.Object, nsname types.NamespacedName) { delete(m.objects, nsname) } @@ -97,7 +150,7 @@ func (m *multiObjectStore) mustFindStoreForObj(obj client.Object) objectStore { } func (m *multiObjectStore) get(objType client.Object, nsname types.NamespacedName) client.Object { - return m.mustFindStoreForObj(objType).get(nsname) + return m.mustFindStoreForObj(objType).get(objType, nsname) } func (m *multiObjectStore) upsert(obj client.Object) { @@ -105,7 +158,7 @@ func (m *multiObjectStore) upsert(obj client.Object) { } func (m *multiObjectStore) delete(objType client.Object, nsname types.NamespacedName) { - m.mustFindStoreForObj(objType).delete(nsname) + m.mustFindStoreForObj(objType).delete(objType, nsname) } func (m *multiObjectStore) persists(objTypeGVK schema.GroupVersionKind) bool { diff --git a/internal/mode/static/state/validation/validator.go b/internal/mode/static/state/validation/validator.go index d6433ad363..1c125f45c9 100644 --- a/internal/mode/static/state/validation/validator.go +++ b/internal/mode/static/state/validation/validator.go @@ -1,11 +1,16 @@ package validation +import ( + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" +) + // Validators include validators for Gateway API resources from the perspective of a data-plane. // It is used for fields that propagate into the data plane configuration. For example, the path in a routing rule. // However, not all such fields are validated: NGF will not validate a field using Validators if it is confident that // the field is valid. type Validators struct { HTTPFieldsValidator HTTPFieldsValidator + PolicyValidator PolicyValidator } // HTTPFieldsValidator validates the HTTP-related fields of Gateway API resources from the perspective of @@ -27,3 +32,11 @@ type HTTPFieldsValidator interface { ValidateRequestHeaderName(name string) error ValidateRequestHeaderValue(value string) error } + +// PolicyValidator validates an NGF Policy. +type PolicyValidator interface { + // Validate validates an NGF Policy. + Validate(policy policies.Policy) error + // Conflicts returns true if the two Policies conflict. + Conflicts(a, b policies.Policy) bool +} diff --git a/internal/mode/static/status/prepare_requests.go b/internal/mode/static/status/prepare_requests.go index 216b3d86d5..cf3eb9f92a 100644 --- a/internal/mode/static/status/prepare_requests.go +++ b/internal/mode/static/status/prepare_requests.go @@ -262,6 +262,51 @@ func prepareGatewayRequest( } } +func PrepareNGFPolicyRequests( + policies map[graph.PolicyKey]*graph.Policy, + transitionTime metav1.Time, + gatewayCtlrName string, +) []frameworkStatus.UpdateRequest { + reqs := make([]frameworkStatus.UpdateRequest, 0, len(policies)) + + for key, pol := range policies { + ancestorStatuses := make([]v1alpha2.PolicyAncestorStatus, 0, len(pol.Ancestors)) + + for _, ancestor := range pol.Ancestors { + allConds := make([]conditions.Condition, 0, len(pol.Conditions)+len(ancestor.Conditions)) + + // We add the ancestor conditions first, so that any policy conditions will override them, which is + // ensured by DeduplicateConditions. + allConds = append(allConds, ancestor.Conditions...) + allConds = append(allConds, pol.Conditions...) + + conds := conditions.DeduplicateConditions(allConds) + apiConds := conditions.ConvertConditions(conds, pol.Source.GetGeneration(), transitionTime) + + ancestorStatuses = append(ancestorStatuses, v1alpha2.PolicyAncestorStatus{ + AncestorRef: createParentReference( + ancestor.Ancestor.Group, + ancestor.Ancestor.Kind, + ancestor.Ancestor.Nsname, + ancestor.Ancestor.SectionName, + ), + ControllerName: v1alpha2.GatewayController(gatewayCtlrName), + Conditions: apiConds, + }) + } + + status := v1alpha2.PolicyStatus{Ancestors: ancestorStatuses} + + reqs = append(reqs, frameworkStatus.UpdateRequest{ + NsName: key.NsName, + ResourceType: pol.Source, + Setter: newNGFPolicyStatusSetter(status, gatewayCtlrName), + }) + } + + return reqs +} + // PrepareBackendTLSPolicyRequests prepares status UpdateRequests for the given BackendTLSPolicies. func PrepareBackendTLSPolicyRequests( policies map[types.NamespacedName]*graph.BackendTLSPolicy, @@ -320,7 +365,9 @@ func PrepareNginxGatewayStatus( var conds []conditions.Condition if cpUpdateRes.Error != nil { msg := "Failed to update control plane configuration" - conds = []conditions.Condition{staticConds.NewNginxGatewayInvalid(fmt.Sprintf("%s: %v", msg, cpUpdateRes.Error))} + conds = []conditions.Condition{ + staticConds.NewNginxGatewayInvalid(fmt.Sprintf("%s: %v", msg, cpUpdateRes.Error)), + } } else { conds = []conditions.Condition{staticConds.NewNginxGatewayValid()} } @@ -333,3 +380,24 @@ func PrepareNginxGatewayStatus( }), } } + +func createParentReference( + group v1.Group, + kind v1.Kind, + nsname types.NamespacedName, + sectionName string, +) v1.ParentReference { + + pr := v1.ParentReference{ + Group: &group, + Kind: &kind, + Namespace: (*v1.Namespace)(&nsname.Namespace), + Name: v1.ObjectName(nsname.Name), + } + + if sectionName != "" { + pr.SectionName = (*v1.SectionName)(§ionName) + } + + return pr +} diff --git a/internal/mode/static/status/status_setters.go b/internal/mode/static/status/status_setters.go index 90fab63d04..c360e46e1d 100644 --- a/internal/mode/static/status/status_setters.go +++ b/internal/mode/static/status/status_setters.go @@ -10,6 +10,7 @@ import ( ngfAPI "github.com/nginxinc/nginx-gateway-fabric/apis/v1alpha1" "github.com/nginxinc/nginx-gateway-fabric/internal/framework/helpers" frameworkStatus "github.com/nginxinc/nginx-gateway-fabric/internal/framework/status" + "github.com/nginxinc/nginx-gateway-fabric/internal/mode/static/policies" ) func newNginxGatewayStatusSetter(status ngfAPI.NginxGatewayStatus) frameworkStatus.Setter { @@ -199,6 +200,38 @@ func newBackendTLSPolicyStatusSetter( } } +func newNGFPolicyStatusSetter( + status gatewayv1alpha2.PolicyStatus, + gatewayCtlrName string, +) frameworkStatus.Setter { + return func(object client.Object) (wasSet bool) { + policy := helpers.MustCastObject[policies.Policy](object) + prevStatus := policy.GetPolicyStatus() + + // maxAncestors is the max number of ancestor statuses which is the sum of all new ancestor statuses and all old + // ancestor statuses. + maxAncestors := len(status.Ancestors) + len(prevStatus.Ancestors) + ancestors := make([]gatewayv1alpha2.PolicyAncestorStatus, 0, maxAncestors) + + // keep all the ancestor statuses that belong to other controllers + for _, as := range prevStatus.Ancestors { + if string(as.ControllerName) != gatewayCtlrName { + ancestors = append(ancestors, as) + } + } + + ancestors = append(ancestors, status.Ancestors...) + status.Ancestors = ancestors + + if btpStatusEqual(gatewayCtlrName, prevStatus, status) { + return false + } + + policy.SetPolicyStatus(status) + return true + } +} + func btpStatusEqual(gatewayCtlrName string, prev, cur gatewayv1alpha2.PolicyStatus) bool { // Since other controllers may update BackendTLSPolicy status we can't assume anything about the order of the // statuses, and we have to ignore statuses written by other controllers when checking for equality.