diff --git a/internal/controller/manager.go b/internal/controller/manager.go index 7d98d3a29b..9a1a1d1581 100644 --- a/internal/controller/manager.go +++ b/internal/controller/manager.go @@ -36,6 +36,7 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + "sigs.k8s.io/gateway-api/pkg/consts" ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha1" ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha2" @@ -493,7 +494,7 @@ func registerControllers( options: []controller.Option{ controller.WithOnlyMetadata(), controller.WithK8sPredicate( - predicate.AnnotationPredicate{Annotation: graph.BundleVersionAnnotation}, + predicate.AnnotationPredicate{Annotation: consts.BundleVersionAnnotation}, ), }, }, diff --git a/internal/controller/state/change_processor.go b/internal/controller/state/change_processor.go index 4e672bec0e..d661903b8c 100644 --- a/internal/controller/state/change_processor.go +++ b/internal/controller/state/change_processor.go @@ -15,6 +15,7 @@ import ( v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" "sigs.k8s.io/gateway-api/apis/v1beta1" + "sigs.k8s.io/gateway-api/pkg/consts" ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha1" ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha2" @@ -63,6 +64,8 @@ type ChangeProcessorConfig struct { GatewayCtlrName string // GatewayClassName is the name of the GatewayClass resource. GatewayClassName string + // ExperimentalFeatures indicates if experimental features are enabled. + ExperimentalFeatures bool } // ChangeProcessorImpl is an implementation of ChangeProcessor. @@ -190,7 +193,7 @@ func NewChangeProcessorImpl(cfg ChangeProcessorConfig) *ChangeProcessorImpl { { gvk: cfg.MustExtractGVK(&apiext.CustomResourceDefinition{}), store: newObjectStoreMapAdapter(clusterStore.CRDMetadata), - predicate: annotationChangedPredicate{annotation: graph.BundleVersionAnnotation}, + predicate: annotationChangedPredicate{annotation: consts.BundleVersionAnnotation}, }, { gvk: cfg.MustExtractGVK(&ngfAPIv1alpha2.NginxProxy{}), @@ -275,6 +278,7 @@ func (c *ChangeProcessorImpl) Process() *graph.Graph { c.cfg.PlusSecrets, c.cfg.Validators, c.cfg.Logger, + c.cfg.ExperimentalFeatures, ) return c.latestGraph diff --git a/internal/controller/state/change_processor_test.go b/internal/controller/state/change_processor_test.go index 6f2e338c07..17af9a4a84 100644 --- a/internal/controller/state/change_processor_test.go +++ b/internal/controller/state/change_processor_test.go @@ -18,6 +18,7 @@ import ( v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" "sigs.k8s.io/gateway-api/apis/v1beta1" + "sigs.k8s.io/gateway-api/pkg/consts" ngfAPIv1alpha1 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha1" ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha2" @@ -666,13 +667,13 @@ var _ = Describe("ChangeProcessor", func() { ObjectMeta: metav1.ObjectMeta{ Name: "gatewayclasses.gateway.networking.k8s.io", Annotations: map[string]string{ - graph.BundleVersionAnnotation: graph.SupportedVersion, + consts.BundleVersionAnnotation: consts.BundleVersion, }, }, } gatewayAPICRDUpdated = gatewayAPICRD.DeepCopy() - gatewayAPICRDUpdated.Annotations[graph.BundleVersionAnnotation] = "v1.99.0" + gatewayAPICRDUpdated.Annotations[consts.BundleVersionAnnotation] = "v1.99.0" }) BeforeEach(func() { expRouteHR1 = &graph.L7Route{ @@ -1556,7 +1557,7 @@ var _ = Describe("ChangeProcessor", func() { } expGraph.GatewayClass.Conditions = conditions.NewGatewayClassSupportedVersionBestEffort( - graph.SupportedVersion, + consts.BundleVersion, ) processAndValidateGraph(expGraph) @@ -1574,7 +1575,7 @@ var _ = Describe("ChangeProcessor", func() { } expGraph.GatewayClass.Conditions = conditions.NewGatewayClassSupportedVersionBestEffort( - graph.SupportedVersion, + consts.BundleVersion, ) graphCfg := processor.Process() diff --git a/internal/controller/state/graph/gatewayclass.go b/internal/controller/state/graph/gatewayclass.go index ff732899ef..7944a0c1f0 100644 --- a/internal/controller/state/graph/gatewayclass.go +++ b/internal/controller/state/graph/gatewayclass.go @@ -8,19 +8,14 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" v1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/pkg/consts" + "sigs.k8s.io/gateway-api/pkg/features" "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/conditions" "github.com/nginx/nginx-gateway-fabric/v2/internal/framework/helpers" "github.com/nginx/nginx-gateway-fabric/v2/internal/framework/kinds" ) -const ( - // BundleVersionAnnotation is the annotation on Gateway API CRDs that contains the installed version. - BundleVersionAnnotation = "gateway.networking.k8s.io/bundle-version" - // SupportedVersion is the supported version of the Gateway API CRDs. - SupportedVersion = "v1.4.0" -) - var gatewayCRDs = map[string]apiVersion{ "gatewayclasses.gateway.networking.k8s.io": {}, "gateways.gateway.networking.k8s.io": {}, @@ -41,6 +36,8 @@ type GatewayClass struct { Conditions []conditions.Condition // Valid shows whether the GatewayClass is valid. Valid bool + // ExperimentalSupported indicates whether experimental features are supported. + ExperimentalSupported bool } // processedGatewayClasses holds the resources that belong to NGF. @@ -83,6 +80,7 @@ func buildGatewayClass( gc *v1.GatewayClass, nps map[types.NamespacedName]*NginxProxy, crdVersions map[types.NamespacedName]*metav1.PartialObjectMetadata, + experimentalEnabled bool, ) *GatewayClass { if gc == nil { return nil @@ -93,13 +91,17 @@ func buildGatewayClass( np = getNginxProxyForGatewayClass(*gc.Spec.ParametersRef, nps) } - conds, valid := validateGatewayClass(gc, np, crdVersions) + conds, valid, crdExperimental := validateGatewayClass(gc, np, crdVersions) + + // Experimental features are supported only if both the config flag AND CRD channel are experimental + experimental := experimentalEnabled && crdExperimental return &GatewayClass{ - Source: gc, - NginxProxy: np, - Valid: valid, - Conditions: conds, + Source: gc, + NginxProxy: np, + Valid: valid, + Conditions: conds, + ExperimentalSupported: experimental, } } @@ -145,14 +147,14 @@ func validateGatewayClass( gc *v1.GatewayClass, npCfg *NginxProxy, crdVersions map[types.NamespacedName]*metav1.PartialObjectMetadata, -) ([]conditions.Condition, bool) { +) ([]conditions.Condition, bool, bool) { var conds []conditions.Condition - supportedVersionConds, versionsValid := validateCRDVersions(crdVersions) + supportedVersionConds, versionsValid, experimental := validateCRDVersions(crdVersions) conds = append(conds, supportedVersionConds...) if gc.Spec.ParametersRef == nil { - return conds, versionsValid + return conds, versionsValid, experimental } path := field.NewPath("spec").Child("parametersRef") @@ -161,7 +163,7 @@ func validateGatewayClass( // return early since parametersRef isn't valid if len(refConds) > 0 { conds = append(conds, refConds...) - return conds, versionsValid + return conds, versionsValid, experimental } if npCfg == nil { @@ -172,7 +174,7 @@ func validateGatewayClass( field.NotFound(path.Child("name"), gc.Spec.ParametersRef.Name).Error(), ), ) - return conds, versionsValid + return conds, versionsValid, experimental } if !npCfg.Valid { @@ -182,10 +184,10 @@ func validateGatewayClass( conditions.NewGatewayClassRefInvalid(msg), conditions.NewGatewayClassInvalidParameters(msg), ) - return conds, versionsValid + return conds, versionsValid, experimental } - return append(conds, conditions.NewGatewayClassResolvedRefs()), versionsValid + return append(conds, conditions.NewGatewayClassResolvedRefs()), versionsValid, experimental } var supportedParamKinds = map[string]struct{}{ @@ -199,9 +201,9 @@ type apiVersion struct { func validateCRDVersions( crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata, -) (conds []conditions.Condition, valid bool) { - installedAPIVersions := getBundleVersions(crdMetadata) - supportedAPIVersion := parseVersionString(SupportedVersion) +) (conds []conditions.Condition, valid bool, experimental bool) { + installedAPIVersions, channels := getBundleVersions(crdMetadata) + supportedAPIVersion := parseVersionString(consts.BundleVersion) var unsupported, bestEffort bool @@ -213,15 +215,23 @@ func validateCRDVersions( } } + // Check if any CRD is using experimental channel + for _, ch := range channels { + if ch == features.FeatureChannelExperimental { + experimental = true + break + } + } + if unsupported { - return conditions.NewGatewayClassUnsupportedVersion(SupportedVersion), false + return conditions.NewGatewayClassUnsupportedVersion(consts.BundleVersion), false, experimental } if bestEffort { - return conditions.NewGatewayClassSupportedVersionBestEffort(SupportedVersion), true + return conditions.NewGatewayClassSupportedVersionBestEffort(consts.BundleVersion), true, experimental } - return nil, true + return nil, true, experimental } func parseVersionString(version string) apiVersion { @@ -246,15 +256,25 @@ func parseVersionString(version string) apiVersion { } } -func getBundleVersions(crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata) []apiVersion { +func getBundleVersions( + crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata, +) ([]apiVersion, []features.FeatureChannel) { versions := make([]apiVersion, 0, len(gatewayCRDs)) + channels := make([]features.FeatureChannel, 0, len(gatewayCRDs)) for nsname, md := range crdMetadata { if _, ok := gatewayCRDs[nsname.Name]; ok { - bundleVersion := md.Annotations[BundleVersionAnnotation] + bundleVersion := md.Annotations[consts.BundleVersionAnnotation] versions = append(versions, parseVersionString(bundleVersion)) + + // Default to standard channel if annotation is missing + ch := md.Annotations[consts.ChannelAnnotation] + if ch == "" { + ch = string(features.FeatureChannelStandard) + } + channels = append(channels, features.FeatureChannel(ch)) } } - return versions + return versions, channels } diff --git a/internal/controller/state/graph/gatewayclass_test.go b/internal/controller/state/graph/gatewayclass_test.go index de37da9c51..6a60835bdf 100644 --- a/internal/controller/state/graph/gatewayclass_test.go +++ b/internal/controller/state/graph/gatewayclass_test.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" v1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/pkg/consts" ngfAPIv1alpha2 "github.com/nginx/nginx-gateway-fabric/v2/apis/v1alpha2" "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/state/conditions" @@ -165,7 +166,7 @@ func TestBuildGatewayClass(t *testing.T) { {Name: "gateways.gateway.networking.k8s.io"}: { ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ - BundleVersionAnnotation: SupportedVersion, + consts.BundleVersionAnnotation: consts.BundleVersion, }, }, }, @@ -175,18 +176,30 @@ func TestBuildGatewayClass(t *testing.T) { {Name: "gateways.gateway.networking.k8s.io"}: { ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ - BundleVersionAnnotation: "v99.0.0", + consts.BundleVersionAnnotation: "v99.0.0", + }, + }, + }, + } + + experimentalCRDs := map[types.NamespacedName]*metav1.PartialObjectMetadata{ + {Name: "gateways.gateway.networking.k8s.io"}: { + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + consts.BundleVersionAnnotation: consts.BundleVersion, + consts.ChannelAnnotation: "experimental", }, }, }, } tests := []struct { - gc *v1.GatewayClass - nps map[types.NamespacedName]*NginxProxy - crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata - expected *GatewayClass - name string + gc *v1.GatewayClass + nps map[types.NamespacedName]*NginxProxy + crdMetadata map[types.NamespacedName]*metav1.PartialObjectMetadata + expected *GatewayClass + name string + experimentalEnabled bool }{ { gc: validGC, @@ -323,10 +336,43 @@ func TestBuildGatewayClass(t *testing.T) { expected: &GatewayClass{ Source: validGC, Valid: false, - Conditions: conditions.NewGatewayClassUnsupportedVersion(SupportedVersion), + Conditions: conditions.NewGatewayClassUnsupportedVersion(consts.BundleVersion), }, name: "invalid gatewayclass; unsupported version", }, + { + gc: validGC, + crdMetadata: experimentalCRDs, + experimentalEnabled: true, + expected: &GatewayClass{ + Source: validGC, + Valid: true, + ExperimentalSupported: true, + }, + name: "experimental enabled and CRDs have experimental channel", + }, + { + gc: validGC, + crdMetadata: validCRDs, + experimentalEnabled: true, + expected: &GatewayClass{ + Source: validGC, + Valid: true, + ExperimentalSupported: false, + }, + name: "experimental enabled but CRDs have standard channel", + }, + { + gc: validGC, + crdMetadata: experimentalCRDs, + experimentalEnabled: false, + expected: &GatewayClass{ + Source: validGC, + Valid: true, + ExperimentalSupported: false, + }, + name: "experimental disabled but CRDs have experimental channel", + }, } for _, test := range tests { @@ -334,7 +380,7 @@ func TestBuildGatewayClass(t *testing.T) { t.Parallel() g := NewWithT(t) - result := buildGatewayClass(test.gc, test.nps, test.crdMetadata) + result := buildGatewayClass(test.gc, test.nps, test.crdMetadata, test.experimentalEnabled) g.Expect(helpers.Diff(test.expected, result)).To(BeEmpty()) }) } @@ -346,14 +392,14 @@ func TestValidateCRDVersions(t *testing.T) { return &metav1.PartialObjectMetadata{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ - BundleVersionAnnotation: version, + consts.BundleVersionAnnotation: version, }, }, } } - // Adding patch version to SupportedVersion to try and avoid having to update these tests with every release. - fields := strings.Split(SupportedVersion, ".") + // Adding patch version to consts.BundleVersion to try and avoid having to update these tests with every release. + fields := strings.Split(consts.BundleVersion, ".") fields[2] = "99" validVersionWithPatch := createCRDMetadata(strings.Join(fields, ".")) @@ -396,7 +442,7 @@ func TestValidateCRDVersions(t *testing.T) { {Name: "referencegrants.gateway.networking.k8s.io"}: bestEffortVersion, }, valid: true, - expConds: conditions.NewGatewayClassSupportedVersionBestEffort(SupportedVersion), + expConds: conditions.NewGatewayClassSupportedVersionBestEffort(consts.BundleVersion), }, { name: "valid; mix of supported and best effort versions", @@ -407,7 +453,7 @@ func TestValidateCRDVersions(t *testing.T) { {Name: "referencegrants.gateway.networking.k8s.io"}: validVersionWithPatch, }, valid: true, - expConds: conditions.NewGatewayClassSupportedVersionBestEffort(SupportedVersion), + expConds: conditions.NewGatewayClassSupportedVersionBestEffort(consts.BundleVersion), }, { name: "invalid; all unsupported versions", @@ -418,7 +464,7 @@ func TestValidateCRDVersions(t *testing.T) { {Name: "referencegrants.gateway.networking.k8s.io"}: unsupportedVersion, }, valid: false, - expConds: conditions.NewGatewayClassUnsupportedVersion(SupportedVersion), + expConds: conditions.NewGatewayClassUnsupportedVersion(consts.BundleVersion), }, { name: "invalid; mix unsupported and best effort versions", @@ -429,7 +475,7 @@ func TestValidateCRDVersions(t *testing.T) { {Name: "referencegrants.gateway.networking.k8s.io"}: bestEffortVersion, }, valid: false, - expConds: conditions.NewGatewayClassUnsupportedVersion(SupportedVersion), + expConds: conditions.NewGatewayClassUnsupportedVersion(consts.BundleVersion), }, { name: "invalid; bad version string", @@ -437,7 +483,7 @@ func TestValidateCRDVersions(t *testing.T) { {Name: "gatewayclasses.gateway.networking.k8s.io"}: createCRDMetadata("v"), }, valid: false, - expConds: conditions.NewGatewayClassUnsupportedVersion(SupportedVersion), + expConds: conditions.NewGatewayClassUnsupportedVersion(consts.BundleVersion), }, } @@ -446,7 +492,7 @@ func TestValidateCRDVersions(t *testing.T) { t.Parallel() g := NewWithT(t) - conds, valid := validateCRDVersions(test.crds) + conds, valid, _ := validateCRDVersions(test.crds) g.Expect(valid).To(Equal(test.valid)) g.Expect(conds).To(Equal(test.expConds)) }) diff --git a/internal/controller/state/graph/graph.go b/internal/controller/state/graph/graph.go index a80fe5426a..924d8b153f 100644 --- a/internal/controller/state/graph/graph.go +++ b/internal/controller/state/graph/graph.go @@ -208,6 +208,7 @@ func BuildGraph( plusSecrets map[types.NamespacedName][]PlusSecretFile, validators validation.Validators, logger logr.Logger, + experimentalEnabled bool, ) *Graph { processedGwClasses, gcExists := processGatewayClasses(state.GatewayClasses, gcName, controllerName) if gcExists && processedGwClasses.Winner == nil { @@ -227,6 +228,7 @@ func BuildGraph( processedGwClasses.Winner, processedNginxProxies, state.CRDMetadata, + experimentalEnabled, ) secretResolver := newSecretResolver(state.Secrets) diff --git a/internal/controller/state/graph/graph_test.go b/internal/controller/state/graph/graph_test.go index a31cf7f1a3..d725b8d2db 100644 --- a/internal/controller/state/graph/graph_test.go +++ b/internal/controller/state/graph/graph_test.go @@ -33,8 +33,9 @@ import ( func TestBuildGraph(t *testing.T) { const ( - gcName = "my-class" - controllerName = "my.controller" + gcName = "my-class" + controllerName = "my.controller" + experimentalFeaturesOff = false ) cm := &v1.ConfigMap{ @@ -1456,6 +1457,7 @@ func TestBuildGraph(t *testing.T) { PolicyValidator: fakePolicyValidator, }, logr.Discard(), + experimentalFeaturesOff, ) g.Expect(helpers.Diff(test.expected, result)).To(BeEmpty()) diff --git a/internal/controller/state/graph/multiple_gateways_test.go b/internal/controller/state/graph/multiple_gateways_test.go index 7c636d7917..c18ef3de0a 100644 --- a/internal/controller/state/graph/multiple_gateways_test.go +++ b/internal/controller/state/graph/multiple_gateways_test.go @@ -22,8 +22,9 @@ import ( ) const ( - controllerName = "nginx" - gcName = "my-gateway-class" + controllerName = "nginx" + gcName = "my-gateway-class" + experimentalFeaturesOff = false ) var ( @@ -406,6 +407,7 @@ func Test_MultipleGateways_WithNginxProxy(t *testing.T) { PolicyValidator: fakePolicyValidator, }, logr.Discard(), + experimentalFeaturesOff, ) g.Expect(helpers.Diff(test.expGraph, result)).To(BeEmpty()) @@ -895,6 +897,7 @@ func Test_MultipleGateways_WithListeners(t *testing.T) { PolicyValidator: fakePolicyValidator, }, logr.Discard(), + experimentalFeaturesOff, ) g.Expect(helpers.Diff(test.expGraph, result)).To(BeEmpty()) diff --git a/internal/controller/status/gatewayclass.go b/internal/controller/status/gatewayclass.go new file mode 100644 index 0000000000..c86faf3865 --- /dev/null +++ b/internal/controller/status/gatewayclass.go @@ -0,0 +1,65 @@ +package status + +import ( + "sort" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/pkg/features" +) + +// supportedFeatures returns the list of features supported by NGINX Gateway Fabric. +// The list must be sorted in ascending alphabetical order. +// If experimental is true, experimental features like TLSRoute will be included. +func supportedFeatures(experimental bool) []gatewayv1.SupportedFeature { + featureNames := []features.FeatureName{ + // Core features + features.SupportGateway, + features.SupportGRPCRoute, + features.SupportHTTPRoute, + features.SupportReferenceGrant, + + // BackendTLSPolicy + features.SupportBackendTLSPolicy, + + // Gateway extended + features.SupportGatewayAddressEmpty, + features.SupportGatewayHTTPListenerIsolation, + features.SupportGatewayInfrastructurePropagation, + features.SupportGatewayPort8080, + features.SupportGatewayStaticAddresses, + + // HTTPRoute extended + features.SupportHTTPRouteBackendProtocolWebSocket, + features.SupportHTTPRouteDestinationPortMatching, + features.SupportHTTPRouteHostRewrite, + features.SupportHTTPRouteMethodMatching, + features.SupportHTTPRouteParentRefPort, + features.SupportHTTPRoutePathRedirect, + features.SupportHTTPRoutePathRewrite, + features.SupportHTTPRoutePortRedirect, + features.SupportHTTPRouteQueryParamMatching, + features.SupportHTTPRouteRequestMirror, + features.SupportHTTPRouteRequestMultipleMirrors, + features.SupportHTTPRouteRequestPercentageMirror, + features.SupportHTTPRouteResponseHeaderModification, + features.SupportHTTPRouteSchemeRedirect, + } + + // Add experimental features if enabled + if experimental { + featureNames = append(featureNames, features.SupportTLSRoute) + } + + // Sort alphabetically by feature name + sort.Slice(featureNames, func(i, j int) bool { + return string(featureNames[i]) < string(featureNames[j]) + }) + + // Convert to SupportedFeature slice + result := make([]gatewayv1.SupportedFeature, 0, len(featureNames)) + for _, name := range featureNames { + result = append(result, gatewayv1.SupportedFeature{Name: gatewayv1.FeatureName(name)}) + } + + return result +} diff --git a/internal/controller/status/gatewayclass_test.go b/internal/controller/status/gatewayclass_test.go new file mode 100644 index 0000000000..69c7e63587 --- /dev/null +++ b/internal/controller/status/gatewayclass_test.go @@ -0,0 +1,103 @@ +package status + +import ( + "slices" + "testing" + + . "github.com/onsi/gomega" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func TestSupportedFeatures(t *testing.T) { + t.Parallel() + + standardFeatures := []gatewayv1.FeatureName{ + gatewayv1.FeatureName(features.SupportBackendTLSPolicy), + gatewayv1.FeatureName(features.SupportGRPCRoute), + gatewayv1.FeatureName(features.SupportGateway), + gatewayv1.FeatureName(features.SupportGatewayAddressEmpty), + gatewayv1.FeatureName(features.SupportGatewayHTTPListenerIsolation), + gatewayv1.FeatureName(features.SupportGatewayInfrastructurePropagation), + gatewayv1.FeatureName(features.SupportGatewayPort8080), + gatewayv1.FeatureName(features.SupportGatewayStaticAddresses), + gatewayv1.FeatureName(features.SupportHTTPRoute), + gatewayv1.FeatureName(features.SupportHTTPRouteBackendProtocolWebSocket), + gatewayv1.FeatureName(features.SupportHTTPRouteDestinationPortMatching), + gatewayv1.FeatureName(features.SupportHTTPRouteHostRewrite), + gatewayv1.FeatureName(features.SupportHTTPRouteMethodMatching), + gatewayv1.FeatureName(features.SupportHTTPRouteParentRefPort), + gatewayv1.FeatureName(features.SupportHTTPRoutePathRedirect), + gatewayv1.FeatureName(features.SupportHTTPRoutePathRewrite), + gatewayv1.FeatureName(features.SupportHTTPRoutePortRedirect), + gatewayv1.FeatureName(features.SupportHTTPRouteQueryParamMatching), + gatewayv1.FeatureName(features.SupportHTTPRouteRequestMirror), + gatewayv1.FeatureName(features.SupportHTTPRouteRequestMultipleMirrors), + gatewayv1.FeatureName(features.SupportHTTPRouteRequestPercentageMirror), + gatewayv1.FeatureName(features.SupportHTTPRouteResponseHeaderModification), + gatewayv1.FeatureName(features.SupportHTTPRouteSchemeRedirect), + gatewayv1.FeatureName(features.SupportReferenceGrant), + } + + experimentalFeatures := []gatewayv1.FeatureName{ + gatewayv1.FeatureName(features.SupportTLSRoute), + } + + allFeatures := append(slices.Clone(standardFeatures), experimentalFeatures...) + + tests := []struct { + name string + expectedFeatures []gatewayv1.FeatureName + unexpectedFeatures []gatewayv1.FeatureName + experimental bool + }{ + { + name: "standard features only", + experimental: false, + expectedFeatures: standardFeatures, + unexpectedFeatures: experimentalFeatures, + }, + { + name: "standard and experimental features", + experimental: true, + expectedFeatures: allFeatures, + unexpectedFeatures: []gatewayv1.FeatureName{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + features := supportedFeatures(tc.experimental) + + g.Expect(features).To(HaveLen(len(tc.expectedFeatures))) + + // Verify all expected features are present + for _, expected := range tc.expectedFeatures { + g.Expect(slices.ContainsFunc(features, func(f gatewayv1.SupportedFeature) bool { + return f.Name == expected + })).To(BeTrue(), "expected feature %s not found", expected) + } + + // Verify unexpected features are not present + for _, unexpected := range tc.unexpectedFeatures { + g.Expect(slices.ContainsFunc(features, func(f gatewayv1.SupportedFeature) bool { + return f.Name == unexpected + })).To(BeFalse(), "unexpected feature %s found", unexpected) + } + + // Verify the list is sorted alphabetically + g.Expect(slices.IsSortedFunc(features, func(a, b gatewayv1.SupportedFeature) int { + if a.Name < b.Name { + return -1 + } + if a.Name > b.Name { + return 1 + } + return 0 + })).To(BeTrue(), "features should be sorted alphabetically") + }) + } +} diff --git a/internal/controller/status/prepare_requests.go b/internal/controller/status/prepare_requests.go index 7c3f161a70..ff51805d1d 100644 --- a/internal/controller/status/prepare_requests.go +++ b/internal/controller/status/prepare_requests.go @@ -210,7 +210,8 @@ func PrepareGatewayClassRequests( NsName: client.ObjectKeyFromObject(gc.Source), ResourceType: &v1.GatewayClass{}, Setter: newGatewayClassStatusSetter(v1.GatewayClassStatus{ - Conditions: apiConds, + Conditions: apiConds, + SupportedFeatures: supportedFeatures(gc.ExperimentalSupported), }), } @@ -227,6 +228,7 @@ func PrepareGatewayClassRequests( gwClass.Generation, transitionTime, ), + SupportedFeatures: supportedFeatures(false), }), } diff --git a/internal/controller/status/prepare_requests_test.go b/internal/controller/status/prepare_requests_test.go index 7ddfdd5e0f..a8cad7b614 100644 --- a/internal/controller/status/prepare_requests_test.go +++ b/internal/controller/status/prepare_requests_test.go @@ -644,6 +644,7 @@ func TestBuildGatewayClassStatuses(t *testing.T) { Message: conditions.GatewayClassMessageGatewayClassConflict, }, }, + SupportedFeatures: supportedFeatures(false), }, {Name: "ignored-2"}: { Conditions: []metav1.Condition{ @@ -656,6 +657,7 @@ func TestBuildGatewayClassStatuses(t *testing.T) { Message: conditions.GatewayClassMessageGatewayClassConflict, }, }, + SupportedFeatures: supportedFeatures(false), }, }, }, @@ -689,6 +691,7 @@ func TestBuildGatewayClassStatuses(t *testing.T) { Message: "The Gateway API CRD versions are supported", }, }, + SupportedFeatures: supportedFeatures(false), }, }, }, diff --git a/internal/controller/status/status_setters.go b/internal/controller/status/status_setters.go index 085ade1e7e..a7147bfa7d 100644 --- a/internal/controller/status/status_setters.go +++ b/internal/controller/status/status_setters.go @@ -41,7 +41,7 @@ func newGatewayStatusSetter(status gatewayv1.GatewayStatus) Setter { func gwStatusEqual(prev, cur gatewayv1.GatewayStatus) bool { addressesEqual := slices.EqualFunc(prev.Addresses, cur.Addresses, func(a1, a2 gatewayv1.GatewayStatusAddress) bool { - if !helpers.EqualPointers[gatewayv1.AddressType](a1.Type, a2.Type) { + if !helpers.EqualPointers(a1.Type, a2.Type) { return false } @@ -217,7 +217,7 @@ func newGatewayClassStatusSetter(status gatewayv1.GatewayClassStatus) Setter { return func(obj client.Object) (wasSet bool) { gc := helpers.MustCastObject[*gatewayv1.GatewayClass](obj) - if ConditionsEqual(gc.Status.Conditions, status.Conditions) { + if gcStatusEqual(gc.Status, status) { return false } @@ -226,6 +226,16 @@ func newGatewayClassStatusSetter(status gatewayv1.GatewayClassStatus) Setter { } } +func gcStatusEqual(prev, cur gatewayv1.GatewayClassStatus) bool { + if !ConditionsEqual(prev.Conditions, cur.Conditions) { + return false + } + + return slices.EqualFunc(prev.SupportedFeatures, cur.SupportedFeatures, func(f1, f2 gatewayv1.SupportedFeature) bool { + return f1.Name == f2.Name + }) +} + func newBackendTLSPolicyStatusSetter( status gatewayv1.PolicyStatus, gatewayCtlrName string, diff --git a/internal/controller/status/status_setters_test.go b/internal/controller/status/status_setters_test.go index da54f7f724..58f290480c 100644 --- a/internal/controller/status/status_setters_test.go +++ b/internal/controller/status/status_setters_test.go @@ -696,6 +696,30 @@ func TestNewGatewayClassStatusSetter(t *testing.T) { }, expStatusSet: false, }, + { + name: "GatewayClass has same conditions but different SupportedFeatures", + newStatus: gatewayv1.GatewayClassStatus{ + Conditions: []metav1.Condition{{Message: "same condition"}}, + SupportedFeatures: []gatewayv1.SupportedFeature{{Name: "Feature1"}}, + }, + status: gatewayv1.GatewayClassStatus{ + Conditions: []metav1.Condition{{Message: "same condition"}}, + SupportedFeatures: []gatewayv1.SupportedFeature{{Name: "Feature2"}}, + }, + expStatusSet: true, + }, + { + name: "GatewayClass has same conditions and same SupportedFeatures", + newStatus: gatewayv1.GatewayClassStatus{ + Conditions: []metav1.Condition{{Message: "same condition"}}, + SupportedFeatures: []gatewayv1.SupportedFeature{{Name: "Feature1"}}, + }, + status: gatewayv1.GatewayClassStatus{ + Conditions: []metav1.Condition{{Message: "same condition"}}, + SupportedFeatures: []gatewayv1.SupportedFeature{{Name: "Feature1"}}, + }, + expStatusSet: false, + }, } for _, test := range tests { diff --git a/tests/Makefile b/tests/Makefile index cb7173afa3..d8be4d2be8 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -12,7 +12,6 @@ GW_SERVICE_TYPE = NodePort## Service type to use for the gateway NGF_VERSION ?= edge## NGF version to be tested PULL_POLICY ?= Never## Pull policy for the images NGINX_CONF_DIR = internal/controller/nginx/conf -SUPPORTED_EXTENDED_FEATURES = HTTPRouteQueryParamMatching,HTTPRouteMethodMatching,HTTPRoutePortRedirect,HTTPRouteSchemeRedirect,HTTPRouteHostRewrite,HTTPRoutePathRewrite,GatewayPort8080,GatewayAddressEmpty,HTTPRouteResponseHeaderModification,HTTPRoutePathRedirect,GatewayHTTPListenerIsolation,GatewayInfrastructurePropagation,HTTPRouteRequestMirror,HTTPRouteRequestMultipleMirrors,HTTPRouteRequestPercentageMirror,HTTPRouteBackendProtocolWebSocket,HTTPRouteParentRefPort,HTTPRouteDestinationPortMatching,GatewayStaticAddresses,BackendTLSPolicy SUPPORTED_EXTENDED_FEATURES_OPENSHIFT = HTTPRouteQueryParamMatching,HTTPRouteMethodMatching,HTTPRoutePortRedirect,HTTPRouteSchemeRedirect,HTTPRouteHostRewrite,HTTPRoutePathRewrite,GatewayPort8080,GatewayAddressEmpty,HTTPRouteResponseHeaderModification,HTTPRoutePathRedirect,GatewayHTTPListenerIsolation,GatewayInfrastructurePropagation,HTTPRouteRequestMirror,HTTPRouteRequestMultipleMirrors,HTTPRouteRequestPercentageMirror,HTTPRouteBackendProtocolWebSocket,HTTPRouteParentRefPort,HTTPRouteDestinationPortMatching STANDARD_CONFORMANCE_PROFILES = GATEWAY-HTTP,GATEWAY-GRPC EXPERIMENTAL_CONFORMANCE_PROFILES = GATEWAY-TLS @@ -58,7 +57,7 @@ run-conformance-tests: ## Run conformance tests --image=$(CONFORMANCE_PREFIX):$(CONFORMANCE_TAG) --image-pull-policy=Never \ --overrides='{ "spec": { "serviceAccountName": "conformance" } }' \ --restart=Never -- sh -c "go test -v . -tags conformance,experimental -args --gateway-class=$(GATEWAY_CLASS) \ - --supported-features=$(SUPPORTED_EXTENDED_FEATURES) --version=$(NGF_VERSION) --skip-tests=$(SKIP_TESTS) --conformance-profiles=$(CONFORMANCE_PROFILES) \ + --version=$(NGF_VERSION) --skip-tests=$(SKIP_TESTS) --conformance-profiles=$(CONFORMANCE_PROFILES) \ --report-output=output.txt; cat output.txt" | tee output.txt ./scripts/check-pod-exit-code.sh conformance sed -e '1,/CONFORMANCE PROFILE/d' output.txt > conformance-profile.yaml