diff --git a/deploy/crds/planetscale.com_etcdlockservers.yaml b/deploy/crds/planetscale.com_etcdlockservers.yaml index 7c58c0913..0d44575e8 100644 --- a/deploy/crds/planetscale.com_etcdlockservers.yaml +++ b/deploy/crds/planetscale.com_etcdlockservers.yaml @@ -48,6 +48,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object createClientService: type: boolean @@ -286,6 +292,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object resources: properties: diff --git a/deploy/crds/planetscale.com_vitesscells.yaml b/deploy/crds/planetscale.com_vitesscells.yaml index aa1ffcbec..035096f2d 100644 --- a/deploy/crds/planetscale.com_vitesscells.yaml +++ b/deploy/crds/planetscale.com_vitesscells.yaml @@ -736,6 +736,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true @@ -837,6 +843,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object createClientService: type: boolean @@ -1075,6 +1087,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object resources: properties: diff --git a/deploy/crds/planetscale.com_vitessclusters.yaml b/deploy/crds/planetscale.com_vitessclusters.yaml index bb90e71bd..61107c60c 100644 --- a/deploy/crds/planetscale.com_vitessclusters.yaml +++ b/deploy/crds/planetscale.com_vitessclusters.yaml @@ -988,6 +988,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true @@ -1043,6 +1049,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object createClientService: type: boolean @@ -1281,6 +1293,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object resources: properties: @@ -1357,6 +1375,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object globalLockserver: properties: @@ -1384,6 +1408,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object createClientService: type: boolean @@ -1622,6 +1652,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object resources: properties: @@ -2882,6 +2918,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true @@ -2901,6 +2943,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object topologyReconciliation: properties: @@ -3109,6 +3157,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true @@ -3307,6 +3361,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true diff --git a/deploy/crds/planetscale.com_vitesskeyspaces.yaml b/deploy/crds/planetscale.com_vitesskeyspaces.yaml index ff9cbc2e5..26ffa39a2 100644 --- a/deploy/crds/planetscale.com_vitesskeyspaces.yaml +++ b/deploy/crds/planetscale.com_vitesskeyspaces.yaml @@ -1379,6 +1379,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true diff --git a/deploy/crds/planetscale.com_vitessshards.yaml b/deploy/crds/planetscale.com_vitessshards.yaml index 45287625f..0730619b0 100644 --- a/deploy/crds/planetscale.com_vitessshards.yaml +++ b/deploy/crds/planetscale.com_vitessshards.yaml @@ -875,6 +875,12 @@ spec: type: object clusterIP: type: string + externalTrafficPolicy: + type: string + loadBalancerIP: + type: string + type: + type: string type: object sidecarContainers: x-kubernetes-preserve-unknown-fields: true diff --git a/pkg/apis/planetscale/v2/vitesscluster_types.go b/pkg/apis/planetscale/v2/vitesscluster_types.go index 3b60361a1..2a089fbef 100644 --- a/pkg/apis/planetscale/v2/vitesscluster_types.go +++ b/pkg/apis/planetscale/v2/vitesscluster_types.go @@ -569,6 +569,34 @@ type ServiceOverrides struct { // initial creation of the Service will only be applied if you manually // delete the Service. ClusterIP string `json:"clusterIP,omitempty"` + + // Type can optionally be used to override the Service's type. + // Defaults to ClusterIP if not specified. Setting this to NodePort or + // LoadBalancer exposes the Service outside the Kubernetes cluster. + // Changes to this field after the initial creation of the Service are + // applied in-place by the operator (Kubernetes supports transitions + // between Service types; some legacy fields like NodePorts assigned + // for the previous type may need to be cleared manually). + // +optional + Type corev1.ServiceType `json:"type,omitempty"` + + // LoadBalancerIP, if set when Type is LoadBalancer, requests the cloud + // load balancer be assigned this specific IP. Only honored by clouds + // (and MetalLB) that support pre-assigned LB IPs. Ignored when Type is + // not LoadBalancer. + // This field is immutable on Service objects, so changes made after + // the initial creation of the Service will only be applied if you + // manually delete the Service. + // +optional + LoadBalancerIP string `json:"loadBalancerIP,omitempty"` + + // ExternalTrafficPolicy, if set, controls how external traffic is + // routed for NodePort and LoadBalancer Services. "Cluster" (default) + // obscures the client source IP but balances across nodes; "Local" + // preserves the source IP at the cost of potentially uneven balancing. + // Ignored when Type is ClusterIP. + // +optional + ExternalTrafficPolicy corev1.ServiceExternalTrafficPolicy `json:"externalTrafficPolicy,omitempty"` } // VitessDashboardStatus is a summary of the status of the vtctld deployment. diff --git a/pkg/operator/update/service.go b/pkg/operator/update/service.go index 421c9dc5a..9d38c959e 100644 --- a/pkg/operator/update/service.go +++ b/pkg/operator/update/service.go @@ -33,9 +33,24 @@ func ServiceOverrides(svc *corev1.Service, so *planetscalev2.ServiceOverrides) { if so.ClusterIP != "" { svc.Spec.ClusterIP = so.ClusterIP } + if so.Type != "" { + svc.Spec.Type = so.Type + } + if so.LoadBalancerIP != "" { + svc.Spec.LoadBalancerIP = so.LoadBalancerIP + } + if so.ExternalTrafficPolicy != "" { + svc.Spec.ExternalTrafficPolicy = so.ExternalTrafficPolicy + } } // InPlaceServiceOverrides applies only the overrides that are safe to update in-place. +// +// Service.Type can be changed in-place by Kubernetes (transitions between +// ClusterIP / NodePort / LoadBalancer are supported), so it's applied here. +// ExternalTrafficPolicy is similarly mutable. +// ClusterIP and LoadBalancerIP are immutable on existing Services and are +// therefore only applied at creation time (see ServiceOverrides above). func InPlaceServiceOverrides(svc *corev1.Service, so *planetscalev2.ServiceOverrides) { if so == nil { return @@ -43,4 +58,10 @@ func InPlaceServiceOverrides(svc *corev1.Service, so *planetscalev2.ServiceOverr if len(so.Annotations) > 0 { Annotations(&svc.Annotations, so.Annotations) } + if so.Type != "" { + svc.Spec.Type = so.Type + } + if so.ExternalTrafficPolicy != "" { + svc.Spec.ExternalTrafficPolicy = so.ExternalTrafficPolicy + } } diff --git a/pkg/operator/update/service_test.go b/pkg/operator/update/service_test.go new file mode 100644 index 000000000..a8ece37c8 --- /dev/null +++ b/pkg/operator/update/service_test.go @@ -0,0 +1,123 @@ +/* +Copyright 2026 PlanetScale Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package update + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + + planetscalev2 "planetscale.dev/vitess-operator/pkg/apis/planetscale/v2" +) + +func TestServiceOverrides_NilIsNoOp(t *testing.T) { + svc := &corev1.Service{} + ServiceOverrides(svc, nil) + if svc.Spec.Type != "" || svc.Spec.ClusterIP != "" { + t.Errorf("nil overrides should not mutate Service: got %+v", svc.Spec) + } +} + +func TestServiceOverrides_AppliesAllFields(t *testing.T) { + svc := &corev1.Service{} + so := &planetscalev2.ServiceOverrides{ + Annotations: map[string]string{"k": "v"}, + ClusterIP: "10.0.0.10", + Type: corev1.ServiceTypeLoadBalancer, + LoadBalancerIP: "192.0.2.10", + ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyLocal, + } + ServiceOverrides(svc, so) + + if got := svc.Annotations["k"]; got != "v" { + t.Errorf("annotations not applied: got %v", svc.Annotations) + } + if svc.Spec.ClusterIP != "10.0.0.10" { + t.Errorf("ClusterIP not applied: got %q", svc.Spec.ClusterIP) + } + if svc.Spec.Type != corev1.ServiceTypeLoadBalancer { + t.Errorf("Type not applied: got %q", svc.Spec.Type) + } + if svc.Spec.LoadBalancerIP != "192.0.2.10" { + t.Errorf("LoadBalancerIP not applied: got %q", svc.Spec.LoadBalancerIP) + } + if svc.Spec.ExternalTrafficPolicy != corev1.ServiceExternalTrafficPolicyLocal { + t.Errorf("ExternalTrafficPolicy not applied: got %q", svc.Spec.ExternalTrafficPolicy) + } +} + +func TestServiceOverrides_EmptyFieldsDoNotOverwrite(t *testing.T) { + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + ClusterIP: "10.0.0.99", + LoadBalancerIP: "192.0.2.99", + ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyLocal, + }, + } + // All-empty overrides struct must not clobber pre-existing Spec fields. + ServiceOverrides(svc, &planetscalev2.ServiceOverrides{}) + + if svc.Spec.Type != corev1.ServiceTypeNodePort { + t.Errorf("Type was unexpectedly reset: got %q", svc.Spec.Type) + } + if svc.Spec.ClusterIP != "10.0.0.99" { + t.Errorf("ClusterIP was unexpectedly reset: got %q", svc.Spec.ClusterIP) + } + if svc.Spec.LoadBalancerIP != "192.0.2.99" { + t.Errorf("LoadBalancerIP was unexpectedly reset: got %q", svc.Spec.LoadBalancerIP) + } + if svc.Spec.ExternalTrafficPolicy != corev1.ServiceExternalTrafficPolicyLocal { + t.Errorf("ExternalTrafficPolicy was unexpectedly reset: got %q", svc.Spec.ExternalTrafficPolicy) + } +} + +func TestInPlaceServiceOverrides_AppliesMutableFieldsOnly(t *testing.T) { + // InPlaceServiceOverrides MUST skip immutable fields (ClusterIP, + // LoadBalancerIP) so reconciles of an existing Service don't fail + // the apiserver's immutable-field validation. + svc := &corev1.Service{ + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.10", + LoadBalancerIP: "192.0.2.10", + }, + } + so := &planetscalev2.ServiceOverrides{ + Annotations: map[string]string{"k": "v"}, + ClusterIP: "10.0.0.20", // must be ignored + Type: corev1.ServiceTypeLoadBalancer, + LoadBalancerIP: "192.0.2.20", // must be ignored + ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyCluster, + } + InPlaceServiceOverrides(svc, so) + + if svc.Spec.ClusterIP != "10.0.0.10" { + t.Errorf("ClusterIP must be immutable in-place: got %q", svc.Spec.ClusterIP) + } + if svc.Spec.LoadBalancerIP != "192.0.2.10" { + t.Errorf("LoadBalancerIP must be immutable in-place: got %q", svc.Spec.LoadBalancerIP) + } + if svc.Spec.Type != corev1.ServiceTypeLoadBalancer { + t.Errorf("Type should be applied in-place: got %q", svc.Spec.Type) + } + if svc.Spec.ExternalTrafficPolicy != corev1.ServiceExternalTrafficPolicyCluster { + t.Errorf("ExternalTrafficPolicy should be applied in-place: got %q", svc.Spec.ExternalTrafficPolicy) + } + if got := svc.Annotations["k"]; got != "v" { + t.Errorf("annotations not applied: got %v", svc.Annotations) + } +}