github.com/cilium/cilium@v1.16.2/operator/pkg/ingress/ingress_reconcile.go (about) 1 // SPDX-License-Identifier: Apache-2.0 2 // Copyright Authors of Cilium 3 4 package ingress 5 6 import ( 7 "cmp" 8 "context" 9 "fmt" 10 "slices" 11 "strings" 12 13 "github.com/sirupsen/logrus" 14 corev1 "k8s.io/api/core/v1" 15 networkingv1 "k8s.io/api/networking/v1" 16 k8serrors "k8s.io/apimachinery/pkg/api/errors" 17 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 "k8s.io/apimachinery/pkg/types" 19 ctrl "sigs.k8s.io/controller-runtime" 20 "sigs.k8s.io/controller-runtime/pkg/client" 21 "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 22 23 controllerruntime "github.com/cilium/cilium/operator/pkg/controller-runtime" 24 "github.com/cilium/cilium/operator/pkg/ingress/annotations" 25 "github.com/cilium/cilium/operator/pkg/model" 26 "github.com/cilium/cilium/operator/pkg/model/ingestion" 27 ciliumv2 "github.com/cilium/cilium/pkg/k8s/apis/cilium.io/v2" 28 "github.com/cilium/cilium/pkg/logging/logfields" 29 ) 30 31 const ( 32 defaultPassthroughPort = uint32(443) 33 defaultInsecureHTTPPort = uint32(80) 34 defaultSecureHTTPPort = uint32(443) 35 defaultHostNetworkListenerPort = uint32(8080) 36 ) 37 38 func (r *ingressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 39 scopedLog := r.logger.WithFields(logrus.Fields{ 40 logfields.Controller: "ingress", 41 logfields.Resource: req.NamespacedName, 42 }) 43 44 scopedLog.Info("Reconciling Ingress") 45 ingress := &networkingv1.Ingress{} 46 if err := r.client.Get(ctx, req.NamespacedName, ingress); err != nil { 47 if !k8serrors.IsNotFound(err) { 48 return controllerruntime.Fail(fmt.Errorf("failed to get Ingress: %w", err)) 49 } 50 // Ingress deleted -> try to cleanup shared CiliumEnvoyConfig 51 // Resources from LB mode dedicated are deleted via K8s Garbage Collection (OwnerReferences) 52 scopedLog.Debug("Trying to cleanup potentially existing resources of deleted Ingress") 53 if err := r.tryCleanupSharedResources(ctx); err != nil { 54 return controllerruntime.Fail(err) 55 } 56 57 return controllerruntime.Success() 58 } 59 60 // Ingress gets deleted via foreground deletion (DeletionTimestamp set) 61 // -> abort and wait for the actual deletion to trigger a reconcile 62 if ingress.GetDeletionTimestamp() != nil { 63 scopedLog.Debug("Ingress is marked for deletion - waiting for actual deletion") 64 return controllerruntime.Success() 65 } 66 67 // Ingress is no longer managed by Cilium. 68 // Trying to cleanup resources. 69 if !isCiliumManagedIngress(ctx, r.client, r.logger, *ingress) { 70 scopedLog.Debug("Trying to cleanup potentially existing resources of unmanaged Ingress") 71 if err := r.tryCleanupSharedResources(ctx); err != nil { 72 return controllerruntime.Fail(err) 73 } 74 75 if err := r.tryCleanupDedicatedResources(ctx, req.NamespacedName); err != nil { 76 return controllerruntime.Fail(err) 77 } 78 79 scopedLog.Debug("Trying to cleanup Ingress status of unmanaged Ingress") 80 if err := r.tryCleanupIngressStatus(ctx, ingress); err != nil { 81 // One attempt to cleanup the status of the Ingress. 82 // Don't fail (and retry) on an error, as this might result in 83 // interferences with the new responsible Ingress controller. 84 scopedLog.WithError(err).Warn("Failed to cleanup Ingress status") 85 } 86 87 scopedLog.Info("Successfully cleaned Ingress resources") 88 return controllerruntime.Success() 89 } 90 91 // Creation / Update of Ingress resources depending on the loadbalancer mode 92 // Trying to cleanup the resources of the "other" mode (potential change of mode) 93 if r.isEffectiveLoadbalancerModeDedicated(ingress) { 94 scopedLog.Debug("Updating dedicated resources") 95 if err := r.createOrUpdateDedicatedResources(ctx, ingress, scopedLog); err != nil { 96 if k8serrors.IsForbidden(err) && k8serrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { 97 // The creation of one of the resources failed because the 98 // namespace is terminating. The ingress itself is also expected 99 // to be marked for deletion, but we haven't yet received the 100 // corresponding event, so let's not print an error message. 101 scopedLog.Info("Aborting reconciliation because namespace is being terminated") 102 return controllerruntime.Success() 103 } 104 105 return controllerruntime.Fail(err) 106 } 107 108 // Trying to cleanup shared resources (potential change of LB mode) 109 scopedLog.Debug("Trying to cleanup potentially existing shared resources") 110 if err := r.tryCleanupSharedResources(ctx); err != nil { 111 return controllerruntime.Fail(err) 112 } 113 } else { 114 scopedLog.Debug("Updating shared resources") 115 if err := r.createOrUpdateSharedResources(ctx); err != nil { 116 return controllerruntime.Fail(err) 117 } 118 119 // Trying to cleanup dedicated resources (potential change of LB mode) 120 scopedLog.Debug("Trying to cleanup potentially existing dedicated resources") 121 if err := r.tryCleanupDedicatedResources(ctx, req.NamespacedName); err != nil { 122 return controllerruntime.Fail(err) 123 } 124 } 125 126 // Update status 127 scopedLog.Debug("Updating Ingress status") 128 if err := r.updateIngressLoadbalancerStatus(ctx, ingress); err != nil { 129 return controllerruntime.Fail(fmt.Errorf("failed to update Ingress loadbalancer status: %w", err)) 130 } 131 132 scopedLog.Info("Successfully reconciled Ingress") 133 return controllerruntime.Success() 134 } 135 136 func (r *ingressReconciler) createOrUpdateDedicatedResources(ctx context.Context, ingress *networkingv1.Ingress, scopedLog logrus.FieldLogger) error { 137 desiredCiliumEnvoyConfig, desiredService, desiredEndpoints, err := r.buildDedicatedResources(ctx, ingress, scopedLog) 138 if err != nil { 139 return fmt.Errorf("failed to build dedicated resources: %w", err) 140 } 141 142 if err := r.createOrUpdateService(ctx, desiredService); err != nil { 143 return err 144 } 145 146 if err := r.createOrUpdateCiliumEnvoyConfig(ctx, desiredCiliumEnvoyConfig); err != nil { 147 return err 148 } 149 150 if err := r.createOrUpdateEndpoints(ctx, desiredEndpoints); err != nil { 151 return err 152 } 153 154 return nil 155 } 156 157 // propagateIngressAnnotationsAndLabels propagates Ingress annotation and label if required. 158 // This is applicable only for dedicated LB mode. 159 // For shared LB mode, the service annotation and label are defined in other higher level (e.g. helm). 160 func (r *ingressReconciler) propagateIngressAnnotationsAndLabels(ingress *networkingv1.Ingress, objectMeta *metav1.ObjectMeta) { 161 // Same lbAnnotationPrefixes config option is used for annotation and label propagation 162 if len(r.lbAnnotationPrefixes) > 0 { 163 objectMeta.Annotations = mergeMap(objectMeta.Annotations, ingress.Annotations, r.lbAnnotationPrefixes...) 164 objectMeta.Labels = mergeMap(objectMeta.Labels, ingress.Labels, r.lbAnnotationPrefixes...) 165 } 166 } 167 168 func (r *ingressReconciler) createOrUpdateSharedResources(ctx context.Context) error { 169 // In shared loadbalancing mode, only the CiliumEnvoyConfig is managed by the Operator. 170 // Service and Endpoints are created by the Helm Chart. 171 desiredCiliumEnvoyConfig, err := r.buildSharedResources(ctx) 172 if err != nil { 173 return fmt.Errorf("failed to build shared resources: %w", err) 174 } 175 176 if err := r.createOrUpdateCiliumEnvoyConfig(ctx, desiredCiliumEnvoyConfig); err != nil { 177 return err 178 } 179 180 return nil 181 } 182 183 func (r *ingressReconciler) tryCleanupDedicatedResources(ctx context.Context, ingressNamespacedName types.NamespacedName) error { 184 resources := map[client.Object]types.NamespacedName{ 185 &corev1.Service{}: {Namespace: ingressNamespacedName.Namespace, Name: fmt.Sprintf("%s-%s", ciliumIngressPrefix, ingressNamespacedName.Name)}, 186 &corev1.Endpoints{}: {Namespace: ingressNamespacedName.Namespace, Name: fmt.Sprintf("%s-%s", ciliumIngressPrefix, ingressNamespacedName.Name)}, 187 &ciliumv2.CiliumEnvoyConfig{}: {Namespace: ingressNamespacedName.Namespace, Name: fmt.Sprintf("%s-%s-%s", ciliumIngressPrefix, ingressNamespacedName.Namespace, ingressNamespacedName.Name)}, 188 } 189 190 for k, v := range resources { 191 if err := r.tryDeletingResource(ctx, k, v); err != nil { 192 return err 193 } 194 } 195 196 return nil 197 } 198 199 func (r *ingressReconciler) tryCleanupSharedResources(ctx context.Context) error { 200 // In shared loadbalancing mode, only the CiliumEnvoyConfig is managed by the Operator. 201 // Service and Endpoints are created by the Helm Chart. 202 desiredCiliumEnvoyConfig, err := r.buildSharedResources(ctx) 203 if err != nil { 204 return fmt.Errorf("failed to build shared resources: %w", err) 205 } 206 207 if err := r.createOrUpdateCiliumEnvoyConfig(ctx, desiredCiliumEnvoyConfig); err != nil { 208 return err 209 } 210 211 return nil 212 } 213 214 func (r *ingressReconciler) buildSharedResources(ctx context.Context) (*ciliumv2.CiliumEnvoyConfig, error) { 215 ingressList := networkingv1.IngressList{} 216 if err := r.client.List(ctx, &ingressList); err != nil { 217 return nil, fmt.Errorf("failed to list Ingresses: %w", err) 218 } 219 220 passthroughPort, insecureHTTPPort, secureHTTPPort := r.getSharedListenerPorts() 221 222 m := &model.Model{} 223 allSharedIngresses := ingressList.Items 224 slices.SortStableFunc(allSharedIngresses, func(a, b networkingv1.Ingress) int { 225 return cmp.Compare(a.Namespace+"/"+a.Name, b.Namespace+"/"+b.Name) 226 }) 227 228 for _, item := range allSharedIngresses { 229 if !isCiliumManagedIngress(ctx, r.client, r.logger, item) || r.isEffectiveLoadbalancerModeDedicated(&item) || item.GetDeletionTimestamp() != nil { 230 continue 231 } 232 if annotations.GetAnnotationTLSPassthroughEnabled(&item) { 233 m.TLSPassthrough = append(m.TLSPassthrough, ingestion.IngressPassthrough(item, passthroughPort)...) 234 } else { 235 m.HTTP = append(m.HTTP, ingestion.Ingress(item, r.defaultSecretNamespace, r.defaultSecretName, r.enforcedHTTPS, insecureHTTPPort, secureHTTPPort, r.defaultRequestTimeout)...) 236 } 237 } 238 239 return r.cecTranslator.Translate(r.ciliumNamespace, r.sharedResourcesName, m) 240 } 241 242 func (r *ingressReconciler) getSharedListenerPorts() (uint32, uint32, uint32) { 243 if !r.hostNetworkEnabled { 244 return defaultPassthroughPort, defaultInsecureHTTPPort, defaultSecureHTTPPort 245 } 246 247 if r.hostNetworkSharedPort > 0 { 248 return r.hostNetworkSharedPort, r.hostNetworkSharedPort, r.hostNetworkSharedPort 249 } 250 251 return defaultHostNetworkListenerPort, defaultHostNetworkListenerPort, defaultHostNetworkListenerPort 252 } 253 254 func (r *ingressReconciler) buildDedicatedResources(ctx context.Context, ingress *networkingv1.Ingress, scopedLog logrus.FieldLogger) (*ciliumv2.CiliumEnvoyConfig, *corev1.Service, *corev1.Endpoints, error) { 255 passthroughPort, insecureHTTPPort, secureHTTPPort := r.getDedicatedListenerPorts(ingress) 256 257 m := &model.Model{} 258 259 if annotations.GetAnnotationTLSPassthroughEnabled(ingress) { 260 m.TLSPassthrough = append(m.TLSPassthrough, ingestion.IngressPassthrough(*ingress, passthroughPort)...) 261 } else { 262 m.HTTP = append(m.HTTP, ingestion.Ingress(*ingress, r.defaultSecretNamespace, r.defaultSecretName, r.enforcedHTTPS, insecureHTTPPort, secureHTTPPort, r.defaultRequestTimeout)...) 263 } 264 265 cec, svc, ep, err := r.dedicatedTranslator.Translate(m) 266 if err != nil { 267 return nil, nil, nil, fmt.Errorf("failed to translate model into resources: %w", err) 268 } 269 270 r.propagateIngressAnnotationsAndLabels(ingress, &svc.ObjectMeta) 271 272 if svc.Spec.Type == corev1.ServiceTypeLoadBalancer { 273 lbClass := annotations.GetAnnotationLoadBalancerClass(ingress) 274 if lbClass != nil { 275 svc.Spec.LoadBalancerClass = lbClass 276 } 277 } 278 279 eTP, err := annotations.GetAnnotationServiceExternalTrafficPolicy(ingress) 280 if err != nil { 281 scopedLog.WithError(err).Warn("Failed to get externalTrafficPolicy annotation from Ingress object") 282 } 283 svc.Spec.ExternalTrafficPolicy = corev1.ServiceExternalTrafficPolicy(eTP) 284 285 // Explicitly set the controlling OwnerReference on the CiliumEnvoyConfig 286 if err := controllerutil.SetControllerReference(ingress, cec, r.client.Scheme()); err != nil { 287 return nil, nil, nil, fmt.Errorf("failed to set controller reference on CiliumEnvoyConfig: %w", err) 288 } 289 290 return cec, svc, ep, err 291 } 292 293 func (r *ingressReconciler) getDedicatedListenerPorts(ingress *networkingv1.Ingress) (uint32, uint32, uint32) { 294 if !r.hostNetworkEnabled { 295 return defaultPassthroughPort, defaultInsecureHTTPPort, defaultSecureHTTPPort 296 } 297 298 port, err := annotations.GetAnnotationHostListenerPort(ingress) 299 if err != nil { 300 r.logger.WithError(err).Warnf("Failed to parse host port - using default listener port") 301 return defaultHostNetworkListenerPort, defaultHostNetworkListenerPort, defaultHostNetworkListenerPort 302 } else if port == nil || *port == 0 { 303 r.logger.Warnf("No host port defined in annotation - using default listener port") 304 return defaultHostNetworkListenerPort, defaultHostNetworkListenerPort, defaultHostNetworkListenerPort 305 } else { 306 return *port, *port, *port 307 } 308 } 309 310 func (r *ingressReconciler) createOrUpdateCiliumEnvoyConfig(ctx context.Context, desiredCEC *ciliumv2.CiliumEnvoyConfig) error { 311 cec := desiredCEC.DeepCopy() 312 313 // Delete CiliumEnvoyConfig if no resources are defined. 314 // Otherwise, the subsequent CreateOrUpdate will fail as spec.resources is required field. 315 if len(cec.Spec.Resources) == 0 { 316 err := r.client.Delete(ctx, cec) 317 if err != nil && !k8serrors.IsNotFound(err) { 318 return fmt.Errorf("failed to delete CiliumEnvoyConfig: %w", err) 319 } 320 return nil 321 } 322 323 result, err := controllerutil.CreateOrUpdate(ctx, r.client, cec, func() error { 324 cec.Spec = desiredCEC.Spec 325 cec.OwnerReferences = desiredCEC.OwnerReferences 326 cec.Annotations = mergeMap(cec.Annotations, desiredCEC.Annotations) 327 cec.Labels = mergeMap(cec.Labels, desiredCEC.Labels) 328 329 return nil 330 }) 331 if err != nil { 332 return fmt.Errorf("failed to create or update CiliumEnvoyConfig: %w", err) 333 } 334 335 r.logger.Debugf("CiliumEnvoyConfig %s has been %s", client.ObjectKeyFromObject(cec), result) 336 337 return nil 338 } 339 340 func (r *ingressReconciler) createOrUpdateService(ctx context.Context, desiredService *corev1.Service) error { 341 svc := desiredService.DeepCopy() 342 343 result, err := controllerutil.CreateOrUpdate(ctx, r.client, svc, func() error { 344 // Save and restore loadBalancerClass 345 // e.g. if a mutating webhook writes this field 346 lbClass := svc.Spec.LoadBalancerClass 347 svc.Spec = desiredService.Spec 348 svc.Spec.LoadBalancerClass = lbClass 349 svc.Spec.ExternalTrafficPolicy = desiredService.Spec.ExternalTrafficPolicy 350 351 svc.OwnerReferences = desiredService.OwnerReferences 352 svc.Annotations = mergeMap(svc.Annotations, desiredService.Annotations) 353 svc.Labels = mergeMap(svc.Labels, desiredService.Labels) 354 355 return nil 356 }) 357 if err != nil { 358 return fmt.Errorf("failed to create or update Service: %w", err) 359 } 360 361 r.logger.Debugf("Service %s has been %s", client.ObjectKeyFromObject(svc), result) 362 363 return nil 364 } 365 366 func (r *ingressReconciler) createOrUpdateEndpoints(ctx context.Context, desiredEndpoints *corev1.Endpoints) error { 367 ep := desiredEndpoints.DeepCopy() 368 369 result, err := controllerutil.CreateOrUpdate(ctx, r.client, ep, func() error { 370 ep.Subsets = desiredEndpoints.Subsets 371 ep.OwnerReferences = desiredEndpoints.OwnerReferences 372 ep.Annotations = mergeMap(ep.Annotations, desiredEndpoints.Annotations) 373 ep.Labels = mergeMap(ep.Labels, desiredEndpoints.Labels) 374 375 return nil 376 }) 377 if err != nil { 378 return fmt.Errorf("failed to create or update Endpoints: %w", err) 379 } 380 381 r.logger.Debugf("Endpoints %s has been %s", client.ObjectKeyFromObject(ep), result) 382 383 return nil 384 } 385 386 // mergeMap merges the content from src into dst. Existing entries are overwritten. 387 // If keyPrefixes are provided, only keys matching one of the prefixes are merged. 388 func mergeMap(dst, src map[string]string, keyPrefixes ...string) map[string]string { 389 if src == nil { 390 return dst 391 } 392 393 if dst == nil { 394 dst = map[string]string{} 395 } 396 397 for key, value := range src { 398 if len(keyPrefixes) == 0 || atLeastOnePrefixMatches(key, keyPrefixes) { 399 dst[key] = value 400 } 401 } 402 403 return dst 404 } 405 406 func atLeastOnePrefixMatches(s string, prefixes []string) bool { 407 for _, p := range prefixes { 408 if strings.HasPrefix(s, p) { 409 return true 410 } 411 } 412 413 return false 414 } 415 416 func (r *ingressReconciler) tryDeletingResource(ctx context.Context, object client.Object, namespacedName types.NamespacedName) error { 417 if err := r.client.Get(ctx, namespacedName, object); err != nil { 418 if !k8serrors.IsNotFound(err) { 419 return fmt.Errorf("failed to get existing %T: %w", object, err) 420 } 421 return nil 422 } 423 424 if err := r.client.Delete(ctx, object); err != nil { 425 return fmt.Errorf("failed to delete existing %T: %w", object, err) 426 } 427 428 return nil 429 } 430 431 func (r *ingressReconciler) updateIngressLoadbalancerStatus(ctx context.Context, ingress *networkingv1.Ingress) error { 432 serviceNamespacedName := types.NamespacedName{} 433 if r.isEffectiveLoadbalancerModeDedicated(ingress) { 434 serviceNamespacedName.Namespace = ingress.Namespace 435 serviceNamespacedName.Name = fmt.Sprintf("%s-%s", ciliumIngressPrefix, ingress.Name) 436 } else { 437 serviceNamespacedName.Namespace = r.ciliumNamespace 438 serviceNamespacedName.Name = r.sharedResourcesName 439 } 440 441 loadbalancerService := corev1.Service{} 442 if err := r.client.Get(ctx, serviceNamespacedName, &loadbalancerService); err != nil { 443 if !k8serrors.IsNotFound(err) { 444 return fmt.Errorf("failed to get loadbalancer Service: %w", err) 445 } 446 447 // Reconcile will be triggered if the loadbalancer Service is updated 448 return nil 449 } 450 451 ingress.Status.LoadBalancer.Ingress = convertToNetworkV1IngressLoadBalancerIngress(loadbalancerService.Status.LoadBalancer.Ingress) 452 453 if err := r.client.Status().Update(ctx, ingress); err != nil { 454 return fmt.Errorf("failed to write Ingress status: %w", err) 455 } 456 457 return nil 458 } 459 460 func (r *ingressReconciler) tryCleanupIngressStatus(ctx context.Context, ingress *networkingv1.Ingress) error { 461 ingress.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{} 462 463 if err := r.client.Status().Update(ctx, ingress); err != nil { 464 return fmt.Errorf("failed to update Ingress status: %w", err) 465 } 466 467 return nil 468 } 469 470 func convertToNetworkV1IngressLoadBalancerIngress(lbIngresses []corev1.LoadBalancerIngress) []networkingv1.IngressLoadBalancerIngress { 471 if lbIngresses == nil { 472 return nil 473 } 474 475 ingLBIngs := make([]networkingv1.IngressLoadBalancerIngress, 0, len(lbIngresses)) 476 for _, lbIng := range lbIngresses { 477 ports := make([]networkingv1.IngressPortStatus, 0, len(lbIng.Ports)) 478 for _, port := range lbIng.Ports { 479 ports = append(ports, networkingv1.IngressPortStatus{ 480 Port: port.Port, 481 Protocol: corev1.Protocol(port.Protocol), 482 Error: port.Error, 483 }) 484 } 485 ingLBIngs = append(ingLBIngs, 486 networkingv1.IngressLoadBalancerIngress{ 487 IP: lbIng.IP, 488 Hostname: lbIng.Hostname, 489 Ports: ports, 490 }) 491 } 492 493 return ingLBIngs 494 } 495 496 func (r *ingressReconciler) isEffectiveLoadbalancerModeDedicated(ingress *networkingv1.Ingress) bool { 497 value := annotations.GetAnnotationIngressLoadbalancerMode(ingress) 498 switch value { 499 case annotations.LoadbalancerModeDedicated: 500 return true 501 case annotations.LoadbalancerModeShared: 502 return false 503 default: 504 return r.defaultLoadbalancerMode == annotations.LoadbalancerModeDedicated 505 } 506 }