diff --git a/apis/projectcontour/v1alpha1/contourdeployment.go b/apis/projectcontour/v1alpha1/contourdeployment.go index 16bd324f0cb..db1b2890bb7 100644 --- a/apis/projectcontour/v1alpha1/contourdeployment.go +++ b/apis/projectcontour/v1alpha1/contourdeployment.go @@ -332,6 +332,28 @@ type NetworkPublishing struct { // // +optional ServiceAnnotations map[string]string `json:"serviceAnnotations,omitempty"` + + // LoadBalancerSourceRanges restricts traffic through the cloud-provider + // load balancer to the specified client IPs. This field will be ignored if + // the cloud-provider does not support this feature. + // + // See: https://kubernetes.io/docs/tasks/access-application-cluster/configure-cloud-provider-firewall/ + // + // +optional + LoadBalancerSourceRanges []string `json:"loadBalancerSourceRanges,omitempty"` + + // LoadBalancerClass is the class of the load balancer implementation this + // Service belongs to. This field can only be set when the type is + // LoadBalancerService. Setting this field on a Service of a different type + // has no effect. This field can only be set when creating or updating a + // Service to the LoadBalancerService type. Once set, it cannot be changed. + // The value of this field corresponds to the value of the + // spec.loadBalancerClass field on the Kubernetes Service. + // + // See: https://kubernetes.io/docs/concepts/services-networking/service/#load-balancer-class + // + // +optional + LoadBalancerClass *string `json:"loadBalancerClass,omitempty"` } // NetworkPublishingType is a way to publish network endpoints. diff --git a/apis/projectcontour/v1alpha1/zz_generated.deepcopy.go b/apis/projectcontour/v1alpha1/zz_generated.deepcopy.go index 241f4c010b6..e5bca8febc7 100644 --- a/apis/projectcontour/v1alpha1/zz_generated.deepcopy.go +++ b/apis/projectcontour/v1alpha1/zz_generated.deepcopy.go @@ -1129,6 +1129,16 @@ func (in *NetworkPublishing) DeepCopyInto(out *NetworkPublishing) { (*out)[key] = val } } + if in.LoadBalancerSourceRanges != nil { + in, out := &in.LoadBalancerSourceRanges, &out.LoadBalancerSourceRanges + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.LoadBalancerClass != nil { + in, out := &in.LoadBalancerClass, &out.LoadBalancerClass + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkPublishing. diff --git a/examples/contour/01-crds.yaml b/examples/contour/01-crds.yaml index 11ab5646794..21226458a4d 100644 --- a/examples/contour/01-crds.yaml +++ b/examples/contour/01-crds.yaml @@ -3792,6 +3792,26 @@ spec: a single IP family on single-stack clusters), or "RequireDualStack" (two IP families on dual-stack configured clusters, otherwise fail). type: string + loadBalancerClass: + description: |- + LoadBalancerClass is the class of the load balancer implementation this + Service belongs to. This field can only be set when the type is + LoadBalancerService. Setting this field on a Service of a different type + has no effect. This field can only be set when creating or updating a + Service to the LoadBalancerService type. Once set, it cannot be changed. + The value of this field corresponds to the value of the + spec.loadBalancerClass field on the Kubernetes Service. + See: https://kubernetes.io/docs/concepts/services-networking/service/#load-balancer-class + type: string + loadBalancerSourceRanges: + description: |- + LoadBalancerSourceRanges restricts traffic through the cloud-provider + load balancer to the specified client IPs. This field will be ignored if + the cloud-provider does not support this feature. + See: https://kubernetes.io/docs/tasks/access-application-cluster/configure-cloud-provider-firewall/ + items: + type: string + type: array serviceAnnotations: additionalProperties: type: string diff --git a/internal/provisioner/controller/gateway.go b/internal/provisioner/controller/gateway.go index 5aab0024283..bfe89b9e12e 100644 --- a/internal/provisioner/controller/gateway.go +++ b/internal/provisioner/controller/gateway.go @@ -308,6 +308,14 @@ func (r *gatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } contourModel.Spec.NetworkPublishing.Envoy.ServiceAnnotations = networkPublishing.ServiceAnnotations + + if len(networkPublishing.LoadBalancerSourceRanges) > 0 { + contourModel.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges = networkPublishing.LoadBalancerSourceRanges + } + + if networkPublishing.LoadBalancerClass != nil { + contourModel.Spec.NetworkPublishing.Envoy.LoadBalancerClass = networkPublishing.LoadBalancerClass + } } // Node placement diff --git a/internal/provisioner/controller/gateway_test.go b/internal/provisioner/controller/gateway_test.go index a88700caf7c..df24a2f7965 100644 --- a/internal/provisioner/controller/gateway_test.go +++ b/internal/provisioner/controller/gateway_test.go @@ -962,6 +962,40 @@ func TestGatewayReconcile(t *testing.T) { assert.Equal(t, int32(30001), svc.Spec.Ports[1].Port) }, }, + "If ContourDeployment.Spec.Envoy.NetworkPublishing sets loadBalancerSourceRanges and loadBalancerClass, the Envoy service reflects them": { + gatewayClass: reconcilableGatewayClassWithParams("gatewayclass-1", controller), + gatewayClassParams: &contour_v1alpha1.ContourDeployment{ + ObjectMeta: meta_v1.ObjectMeta{ + Namespace: "projectcontour", + Name: "gatewayclass-1-params", + }, + Spec: contour_v1alpha1.ContourDeploymentSpec{ + Envoy: &contour_v1alpha1.EnvoySettings{ + NetworkPublishing: &contour_v1alpha1.NetworkPublishing{ + Type: contour_v1alpha1.LoadBalancerServicePublishingType, + LoadBalancerSourceRanges: []string{"10.0.0.0/8", "192.168.0.0/16"}, + LoadBalancerClass: ptr.To("example.io/my-lb"), + }, + }, + }, + }, + gateway: makeGateway(), + assertions: func(t *testing.T, r *gatewayReconciler, _ *gatewayapi_v1.Gateway, reconcileErr error) { + require.NoError(t, reconcileErr) + + svc := &core_v1.Service{ + ObjectMeta: meta_v1.ObjectMeta{ + Namespace: "gateway-1", + Name: "envoy-gateway-1", + }, + } + require.NoError(t, r.client.Get(context.Background(), keyFor(svc), svc)) + assert.Equal(t, core_v1.ServiceTypeLoadBalancer, svc.Spec.Type) + assert.Equal(t, []string{"10.0.0.0/8", "192.168.0.0/16"}, svc.Spec.LoadBalancerSourceRanges) + require.NotNil(t, svc.Spec.LoadBalancerClass) + assert.Equal(t, "example.io/my-lb", *svc.Spec.LoadBalancerClass) + }, + }, "If ContourDeployment.Spec.Envoy.WorkloadType is set to Deployment, an Envoy deployment is provisioned with the specified number of replicas": { gatewayClass: reconcilableGatewayClassWithParams("gatewayclass-1", controller), gatewayClassParams: &contour_v1alpha1.ContourDeployment{ diff --git a/internal/provisioner/equality/equality.go b/internal/provisioner/equality/equality.go index 336fb5905b7..c08d547069c 100644 --- a/internal/provisioner/equality/equality.go +++ b/internal/provisioner/equality/equality.go @@ -187,6 +187,16 @@ func LoadBalancerServiceChanged(current, expected *core_v1.Service) (*core_v1.Se changed = true } + if !apiequality.Semantic.DeepEqual(current.Spec.LoadBalancerSourceRanges, expected.Spec.LoadBalancerSourceRanges) { + updated.Spec.LoadBalancerSourceRanges = expected.Spec.LoadBalancerSourceRanges + changed = true + } + + if !apiequality.Semantic.DeepEqual(current.Spec.LoadBalancerClass, expected.Spec.LoadBalancerClass) { + updated.Spec.LoadBalancerClass = expected.Spec.LoadBalancerClass + changed = true + } + if !changed { return nil, false } diff --git a/internal/provisioner/equality/equality_test.go b/internal/provisioner/equality/equality_test.go index a6ba0641b7b..b67c9d672b8 100644 --- a/internal/provisioner/equality/equality_test.go +++ b/internal/provisioner/equality/equality_test.go @@ -459,6 +459,28 @@ func TestLoadBalancerServiceChanged(t *testing.T) { }, expect: true, }, + { + description: "if load balancer source ranges changed", + mutate: func(svc *core_v1.Service) { + svc.Spec.LoadBalancerSourceRanges = []string{"10.0.0.0/8"} + }, + expect: true, + }, + { + description: "if load balancer source ranges unchanged", + mutate: func(_ *core_v1.Service) { + // no-op: cntr is pre-populated with source ranges in the loop below, + // so both mutated and expected have the same non-empty value. + }, + expect: false, + }, + { + description: "if load balancer class changed", + mutate: func(svc *core_v1.Service) { + svc.Spec.LoadBalancerClass = ptr.To("other.io/lb") + }, + expect: true, + }, } for _, tc := range testCases { @@ -466,6 +488,10 @@ func TestLoadBalancerServiceChanged(t *testing.T) { cntr.Spec.NetworkPublishing.Envoy.Type = model.LoadBalancerServicePublishingType cntr.Spec.NetworkPublishing.Envoy.LoadBalancer.Scope = model.ExternalLoadBalancer cntr.Spec.NetworkPublishing.Envoy.LoadBalancer.ProviderParameters.Type = model.AWSLoadBalancerProvider + cntr.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges = nil + if tc.description == "if load balancer source ranges unchanged" { + cntr.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges = []string{"10.0.0.0/8"} + } if tc.description == "if load balancer IP changed" { loadBalancerIP := "1.2.3.4" cntr = &model.Contour{ diff --git a/internal/provisioner/model/model.go b/internal/provisioner/model/model.go index ad60c2548e3..16c827a629a 100644 --- a/internal/provisioner/model/model.go +++ b/internal/provisioner/model/model.go @@ -436,6 +436,14 @@ type EnvoyNetworkPublishing struct { // // If unset, defaults to "Local". ExternalTrafficPolicy core_v1.ServiceExternalTrafficPolicyType + + // LoadBalancerSourceRanges restricts traffic through the cloud-provider + // load balancer to the specified client IPs. + LoadBalancerSourceRanges []string + + // LoadBalancerClass is the class of the load balancer implementation + // this Service belongs to. Once set on a created Service, it cannot be changed. + LoadBalancerClass *string } type NetworkPublishingType = contour_v1alpha1.NetworkPublishingType diff --git a/internal/provisioner/objects/service/service.go b/internal/provisioner/objects/service/service.go index bebdf931d0e..d87daa2f990 100644 --- a/internal/provisioner/objects/service/service.go +++ b/internal/provisioner/objects/service/service.go @@ -271,6 +271,12 @@ func DesiredEnvoyService(contour *model.Contour) *core_v1.Service { internalAnnotations := InternalLBAnnotations[provider] maps.Copy(svc.Annotations, internalAnnotations) } + if len(contour.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges) > 0 { + svc.Spec.LoadBalancerSourceRanges = contour.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges + } + if contour.Spec.NetworkPublishing.Envoy.LoadBalancerClass != nil { + svc.Spec.LoadBalancerClass = contour.Spec.NetworkPublishing.Envoy.LoadBalancerClass + } case model.NodePortServicePublishingType: svc.Spec.Type = core_v1.ServiceTypeNodePort diff --git a/internal/provisioner/objects/service/service_test.go b/internal/provisioner/objects/service/service_test.go index faf2a89b602..ebc26cc15c4 100644 --- a/internal/provisioner/objects/service/service_test.go +++ b/internal/provisioner/objects/service/service_test.go @@ -148,6 +148,48 @@ func checkServiceHasLoadBalancerAddress(t *testing.T, svc *core_v1.Service, addr } } +func checkServiceHasLoadBalancerSourceRanges(t *testing.T, svc *core_v1.Service, ranges []string) { + t.Helper() + + if len(svc.Spec.LoadBalancerSourceRanges) != len(ranges) { + t.Errorf("service has %d loadBalancerSourceRanges, want %d", len(svc.Spec.LoadBalancerSourceRanges), len(ranges)) + return + } + for i, r := range ranges { + if svc.Spec.LoadBalancerSourceRanges[i] != r { + t.Errorf("service loadBalancerSourceRanges[%d] = %s, want %s", i, svc.Spec.LoadBalancerSourceRanges[i], r) + } + } +} + +func checkServiceHasNoLoadBalancerSourceRanges(t *testing.T, svc *core_v1.Service) { + t.Helper() + + if len(svc.Spec.LoadBalancerSourceRanges) != 0 { + t.Errorf("service has unexpected loadBalancerSourceRanges: %v", svc.Spec.LoadBalancerSourceRanges) + } +} + +func checkServiceHasLoadBalancerClass(t *testing.T, svc *core_v1.Service, class string) { + t.Helper() + + if svc.Spec.LoadBalancerClass == nil { + t.Errorf("service has nil loadBalancerClass, want %s", class) + return + } + if *svc.Spec.LoadBalancerClass != class { + t.Errorf("service loadBalancerClass = %s, want %s", *svc.Spec.LoadBalancerClass, class) + } +} + +func checkServiceHasNoLoadBalancerClass(t *testing.T, svc *core_v1.Service) { + t.Helper() + + if svc.Spec.LoadBalancerClass != nil { + t.Errorf("service has unexpected loadBalancerClass: %s", *svc.Spec.LoadBalancerClass) + } +} + func TestDesiredContourService(t *testing.T) { name := "svc-test" cntr := model.Default(fmt.Sprintf("%s-ns", name), name) @@ -283,4 +325,35 @@ func TestDesiredEnvoyService(t *testing.T) { svc = DesiredEnvoyService(cntr) checkServiceHasType(t, svc, core_v1.ServiceTypeClusterIP) checkServiceHasAnnotations(t, svc) // passing no keys means we expect no annotations + + // Test LoadBalancerSourceRanges is set for LoadBalancerService type. + cntr.Spec.NetworkPublishing.Envoy.Type = model.LoadBalancerServicePublishingType + cntr.Spec.NetworkPublishing.Envoy.LoadBalancer.Scope = model.ExternalLoadBalancer + cntr.Spec.NetworkPublishing.Envoy.LoadBalancer.ProviderParameters = model.ProviderLoadBalancerParameters{Type: model.AWSLoadBalancerProvider} + cntr.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges = []string{"10.0.0.0/8", "192.168.0.0/16"} + svc = DesiredEnvoyService(cntr) + checkServiceHasType(t, svc, core_v1.ServiceTypeLoadBalancer) + checkServiceHasLoadBalancerSourceRanges(t, svc, []string{"10.0.0.0/8", "192.168.0.0/16"}) + + // Test LoadBalancerSourceRanges is NOT set for NodePortService type. + cntr.Spec.NetworkPublishing.Envoy.Type = model.NodePortServicePublishingType + svc = DesiredEnvoyService(cntr) + checkServiceHasNoLoadBalancerSourceRanges(t, svc) + + // Clear source ranges so they don't affect subsequent LoadBalancerClass assertions, + // since cntr is shared state across all test blocks in this function. + cntr.Spec.NetworkPublishing.Envoy.LoadBalancerSourceRanges = nil + + // Test LoadBalancerClass is set for LoadBalancerService type. + lbClass := "example.io/my-lb" + cntr.Spec.NetworkPublishing.Envoy.Type = model.LoadBalancerServicePublishingType + cntr.Spec.NetworkPublishing.Envoy.LoadBalancerClass = &lbClass + svc = DesiredEnvoyService(cntr) + checkServiceHasType(t, svc, core_v1.ServiceTypeLoadBalancer) + checkServiceHasLoadBalancerClass(t, svc, lbClass) + + // Test LoadBalancerClass is NOT set for NodePortService type. + cntr.Spec.NetworkPublishing.Envoy.Type = model.NodePortServicePublishingType + svc = DesiredEnvoyService(cntr) + checkServiceHasNoLoadBalancerClass(t, svc) } diff --git a/site/content/docs/main/config/gateway-api.md b/site/content/docs/main/config/gateway-api.md index c38c2b49c04..b17262eb837 100644 --- a/site/content/docs/main/config/gateway-api.md +++ b/site/content/docs/main/config/gateway-api.md @@ -164,6 +164,27 @@ All Gateways provisioned using the `contour-with-envoy-deployment` GatewayClass See [the API documentation][6] for all `ContourDeployment` options. +For example, to restrict access to the provisioned Envoy load balancer to specific source IP ranges and to select a specific load balancer implementation class: + +```yaml +kind: ContourDeployment +apiVersion: projectcontour.io/v1alpha1 +metadata: + namespace: projectcontour + name: contour-with-lb-params +spec: + envoy: + networkPublishing: + type: LoadBalancerService + loadBalancerSourceRanges: + - 10.0.0.0/8 + - 192.168.0.0/16 + loadBalancerClass: example.io/my-lb +``` + +> **Note:** `loadBalancerClass` is immutable on a Kubernetes Service once set. +> To change it you must delete and re-create the Gateway. + It's important to note that, per the [GatewayClass spec][10]: > It is recommended that [GatewayClass] be used as a template for Gateways.