Repository: velero-io/velero (2026-03-26T11:28:33Z)

Issue:
Restore fails when Service last-applied-configuration has "healthCheckNodePort": null
**What steps did you take and what happened:**

1. Cluster has a Service of type LoadBalancer with spec.externalTrafficPolicy: Local and a non-zero spec.healthCheckNodePort assigned by the apiserver
2. The kubectl.kubernetes.io/last-applied-configuration annotation contains JSON with "healthCheckNodePort": null
3. Backup the namespace, then restore without --preserve-nodeports.

_What happened:_ Restore fails in the built-in Service restore item action with an error like:
```
Errors:
  Velero:     <none>
  Cluster:    <none>
  Namespaces:
    xxx:  error preparing services/xxx/syslog-listener-tcp-load-balancer: rpc error: code = Unknown desc = .spec.healthCheckNodePort accessor error: <nil> is of the type <nil>, expected float64
                error preparing services/xxx/syslog-listener-udp-load-balancer: rpc error: code = Unknown desc = .spec.healthCheckNodePort accessor error: <nil> is of the type <nil>, expected float64
                error preparing services/xxx/trap-listener-udp-load-balancer: rpc error: code = Unknown desc = .spec.healthCheckNodePort accessor error: <nil> is of the type <nil>, expected float64
```

**What did you expect to happen:**
Restore should succeed. null in last-applied should be treated as “the user did not specify an explicit health check node port”, so auto-assigned ports can be cleared when appropriate, without failing.

**The following information will help us better understand what's going on**:
Enviroment: GKE

Error from velero describe
```
Errors:
  Velero:     <none>
  Cluster:    <none>
  Namespaces:
    xxx:  error preparing services/xxx/syslog-listener-tcp-load-balancer: rpc error: code = Unknown desc = .spec.healthCheckNodePort accessor error: <nil> is of the type <nil>, expected float64
                error preparing services/xxx/syslog-listener-udp-load-balancer: rpc error: code = Unknown desc = .spec.healthCheckNodePort accessor error: <nil> is of the type <nil>, expected float64
                error preparing services/xxx/trap-listener-udp-load-balancer: rpc error: code = Unknown desc = .spec.healthCheckNodePort accessor error: <nil> is of the type <nil>, expected float64
```

Example of problem service:


```
apiVersion: v1
kind: Service
metadata:
  annotations:
    cloud.google.com/neg: '{"ingress":true}'
    cloud.google.com/neg-status: '{"network_endpoint_groups":{"0":"k8s2-fi0coc1c-xxx-trap-listener-udp-load-balance-bpyk6ezf"},"zones":["us-central1-a","us-central1-b","us-central1-c"]}'
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"v1","kind":"Service","metadata":{"annotations":{"networking.gke.io/internal-load-balancer-subnet":"subnet-gke-xxx","networking.gke.io/load-balancer-type":"Internal"},"labels":{"app.kubernetes.io/managed-by":"saasDeployer","app.kubernetes.io/name":"trap-listener","app.kubernetes.io/part-of":"resource-monitoring.fault-management.trap-listener","deployment.netcracker.com/session-id":"0416b7ae-2348-4a6a-b8c4-e404c5c892b6","name":"trap-listener-v1"},"name":"trap-listener-udp-load-balancer","namespace":"xxx"},"spec":{"externalTrafficPolicy":"Local","healthCheckNodePort":null,"internalTrafficPolicy":"Local","loadBalancerIP":"10.98.35.30","ports":[{"name":"162-udp","nodePort":31701,"port":162,"protocol":"UDP","targetPort":1162}],"selector":{"name":"trap-listener-v1"},"type":"LoadBalancer"}}
    networking.gke.io/internal-load-balancer-subnet: subnet-gke-xxx
    networking.gke.io/load-balancer-type: Internal
    service.kubernetes.io/backend-service: k8s2-fi0coc1c-xxx-trap-listener-udp-load-balance-bpyk6ezf
    service.kubernetes.io/firewall-rule: k8s2-fi0coc1c-xxx-trap-listener-udp-load-balance-bpyk6ezf
    service.kubernetes.io/firewall-rule-for-hc: k8s2-fi0coc1c-xxx-trap-listener-udp-load-balance-bpyk6-fw
    service.kubernetes.io/healthcheck: k8s2-fi0coc1c-xxx-trap-listener-udp-load-balance-bpyk6ezf
    service.kubernetes.io/udp-forwarding-rule: k8s2-udp-fi0coc1c-xxx-trap-listener-udp-load-bala-bpyk6ezf
  creationTimestamp: "2025-12-23T03:30:06Z"
  finalizers:
  - gke.networking.io/l4-ilb-v2
  - service.kubernetes.io/load-balancer-cleanup
  labels:
    app.kubernetes.io/managed-by: saasDeployer
    app.kubernetes.io/name: trap-listener
    app.kubernetes.io/part-of: resource-monitoring.fault-management.trap-listener
    deployment.netcracker.com/session-id: 0416b7ae-2348-4a6a-b8c4-e404c5c892b6
    name: trap-listener-v1
  name: trap-listener-udp-load-balancer
  namespace: xxx
  resourceVersion: "1774236815457439004"
  uid: b6968d95-ee64-42ba-bf94-891f82bf6971
spec:
  allocateLoadBalancerNodePorts: true
  clusterIP: 10.201.2.128
  clusterIPs:
  - 10.201.2.128
  externalTrafficPolicy: Local
  healthCheckNodePort: 31743
  internalTrafficPolicy: Local
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  loadBalancerIP: 10.98.35.30
  ports:
  - name: 162-udp
    nodePort: 31701
    port: 162
    protocol: UDP
    targetPort: 1162
  selector:
    name: trap-listener-v1
  sessionAffinity: None
  type: LoadBalancer
status:
  loadBalancer:
    ingress:
    - ip: 10.98.35.30
      ipMode: VI
```

**Anything else you would like to add:**
Affected code: deleteHealthCheckNodePort in service_action.go, NestedFloat64 on the unmarshalled last-applied object.
Suggested direction: use NestedFieldNoCopy (or similar); if the value is missing or nil, do not treat it as an explicit port; if it is a number, keep current behavior (similar to nodePort handling in deleteNodePorts).


**Environment:**

- Velero version (use `velero version`):  v1.17.2
- Velero features (use `velero client config get features`): -
- Kubernetes version (use `kubectl version`): Server Version: v1.33.5-gke.2469000
- Kubernetes installer & version: -
- Cloud provider or hardware configuration: GCP
- OS (e.g. from `/etc/os-release`): -


**Vote on this issue!**

This is an invitation to the Velero community to vote on issues, you can see the project's [top voted issues listed here](https://github.com/vmware-tanzu/velero/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc).  
Use the "reaction smiley face" up to the right of this comment to vote.

- :+1: for "I would like to see this bug fixed as soon as possible"
- :-1: for "There are more important bugs to focus on right now"



Discussion:

Patch:
diff --git a/changelogs/unreleased/9653-BassinD b/changelogs/unreleased/9653-BassinD
new file mode 100644
index 0000000000..d4eefe02a5
--- /dev/null
+++ b/changelogs/unreleased/9653-BassinD
@@ -0,0 +1,1 @@
+Fix service restore with null healthCheckNodePort in last-applied-configuration label
diff --git a/pkg/restore/actions/service_action.go b/pkg/restore/actions/service_action.go
index 9de75228e7..34ba85e93a 100644
--- a/pkg/restore/actions/service_action.go
+++ b/pkg/restore/actions/service_action.go
@@ -94,21 +94,18 @@ func deleteHealthCheckNodePort(service *corev1api.Service) error {
 
 	// Search HealthCheckNodePort from server's last-applied-configuration
 	// annotation(HealthCheckNodePort is specified by `kubectl apply` command)
-	lastAppliedConfig, ok := service.Annotations[annotationLastAppliedConfig]
-	if ok {
-		appliedServiceUnstructured := new(map[string]any)
-		if err := json.Unmarshal([]byte(lastAppliedConfig), appliedServiceUnstructured); err != nil {
-			return errors.WithStack(err)
+	if lastAppliedConfig, ok := service.Annotations[annotationLastAppliedConfig]; ok {
+		var appliedConfig struct {
+			Spec struct {
+				HealthCheckNodePort *int32 `json:"healthCheckNodePort"`
+			} `json:"spec"`
 		}
 
-		healthCheckNodePort, exist, err := unstructured.NestedFloat64(*appliedServiceUnstructured, "spec", "healthCheckNodePort")
-		if err != nil {
+		if err := json.Unmarshal([]byte(lastAppliedConfig), &appliedConfig); err != nil {
 			return errors.WithStack(err)
 		}
 
-		// Found healthCheckNodePort in lastAppliedConfig annotation,
-		// and the value is not 0. No need to delete, return.
-		if exist && healthCheckNodePort != 0 {
+		if appliedConfig.Spec.HealthCheckNodePort != nil && *appliedConfig.Spec.HealthCheckNodePort != 0 {
 			return nil
 		}
 	}


Test patch:
diff --git a/pkg/restore/actions/service_action_test.go b/pkg/restore/actions/service_action_test.go
index 42c0d290d1..f9a01d5d4b 100644
--- a/pkg/restore/actions/service_action_test.go
+++ b/pkg/restore/actions/service_action_test.go
@@ -644,6 +644,36 @@ func TestServiceActionExecute(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "If PreserveNodePorts is false and HealthCheckNodePort is null in last-applied-configuration, it should not crash and the port should be cleared.",
+			obj: corev1api.Service{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "svc-1",
+					Annotations: map[string]string{
+						"kubectl.kubernetes.io/last-applied-configuration": `{"spec":{"healthCheckNodePort":null}}`,
+					},
+				},
+				Spec: corev1api.ServiceSpec{
+					HealthCheckNodePort:   8080,
+					ExternalTrafficPolicy: corev1api.ServiceExternalTrafficPolicyTypeLocal,
+					Type:                  corev1api.ServiceTypeLoadBalancer,
+				},
+			},
+			restore: builder.ForRestore(api.DefaultNamespace, "").PreserveNodePorts(false).Result(),
+			expectedRes: corev1api.Service{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "svc-1",
+					Annotations: map[string]string{
+						"kubectl.kubernetes.io/last-applied-configuration": `{"spec":{"healthCheckNodePort":null}}`,
+					},
+				},
+				Spec: corev1api.ServiceSpec{
+					HealthCheckNodePort:   0,
+					ExternalTrafficPolicy: corev1api.ServiceExternalTrafficPolicyTypeLocal,
+					Type:                  corev1api.ServiceTypeLoadBalancer,
+				},
+			},
+		},
 	}
 
 	for _, test := range tests {