github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/controllers/core/kubernetesdiscovery/portforwards.go (about)

     1  package kubernetesdiscovery
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  
     7  	v1 "k8s.io/api/core/v1"
     8  	apierrors "k8s.io/apimachinery/pkg/api/errors"
     9  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    10  	"k8s.io/apimachinery/pkg/types"
    11  	errorutil "k8s.io/apimachinery/pkg/util/errors"
    12  	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    13  
    14  	"github.com/tilt-dev/tilt/internal/controllers/apicmp"
    15  	"github.com/tilt-dev/tilt/internal/controllers/indexer"
    16  	"github.com/tilt-dev/tilt/internal/store"
    17  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    18  	"github.com/tilt-dev/tilt/pkg/logger"
    19  )
    20  
    21  // Reconcile all the port forwards owned by this KD. The KD may be nil if it's being deleted.
    22  func (r *Reconciler) manageOwnedPortForwards(ctx context.Context, nn types.NamespacedName, kd *v1alpha1.KubernetesDiscovery) error {
    23  	var pfList v1alpha1.PortForwardList
    24  	err := indexer.ListOwnedBy(ctx, r.ctrlClient, &pfList, nn, apiType)
    25  	if err != nil {
    26  		return fmt.Errorf("failed to fetch managed PortForward objects for KubernetesDiscovery %s: %v",
    27  			nn.Name, err)
    28  	}
    29  
    30  	pf, err := r.toDesiredPortForward(kd)
    31  	if err != nil {
    32  		return fmt.Errorf("creating portforward: %v", err)
    33  	}
    34  
    35  	// Delete all the port-forwards that don't match this one.
    36  	errs := []error{}
    37  	foundDesired := false
    38  	for _, existingPF := range pfList.Items {
    39  		matchesPF := pf != nil && existingPF.Name == pf.Name
    40  		if matchesPF {
    41  			foundDesired = true
    42  
    43  			// If this PortForward is already in the APIServer, make sure it's up-to-date.
    44  			if apicmp.DeepEqual(pf.Spec, existingPF.Spec) {
    45  				continue
    46  			}
    47  
    48  			updatedPF := existingPF.DeepCopy()
    49  			updatedPF.Spec = pf.Spec
    50  			err := r.ctrlClient.Update(ctx, updatedPF)
    51  			if err != nil && !apierrors.IsNotFound(err) {
    52  				errs = append(errs, fmt.Errorf("updating portforward %s: %v", existingPF.Name, err))
    53  			} else {
    54  				warnDeprecatedImplicitForwards(ctx, kd, pf)
    55  			}
    56  			continue
    57  		}
    58  
    59  		// If this does not match the desired PF, this PF needs to be garbage collected.
    60  		deletedPF := existingPF.DeepCopy()
    61  		err := r.ctrlClient.Delete(ctx, deletedPF)
    62  		if err != nil && !apierrors.IsNotFound(err) {
    63  			errs = append(errs, fmt.Errorf("deleting portforward %s: %v", existingPF.Name, err))
    64  		}
    65  	}
    66  
    67  	if !foundDesired && pf != nil {
    68  		err := r.ctrlClient.Create(ctx, pf)
    69  		if err != nil && !apierrors.IsAlreadyExists(err) {
    70  			errs = append(errs, fmt.Errorf("creating portforward %s: %v", pf.Name, err))
    71  		} else {
    72  			warnDeprecatedImplicitForwards(ctx, kd, pf)
    73  		}
    74  	}
    75  
    76  	return errorutil.NewAggregate(errs)
    77  }
    78  
    79  // Construct the desired port-forward. May be nil.
    80  func (r *Reconciler) toDesiredPortForward(kd *v1alpha1.KubernetesDiscovery) (*v1alpha1.PortForward, error) {
    81  	if kd == nil {
    82  		return nil, nil
    83  	}
    84  
    85  	pfTemplate := kd.Spec.PortForwardTemplateSpec
    86  	if pfTemplate == nil {
    87  		return nil, nil
    88  	}
    89  
    90  	pod := PickBestPortForwardPod(kd)
    91  	if pod == nil {
    92  		return nil, nil
    93  	}
    94  
    95  	pf := &v1alpha1.PortForward{
    96  		ObjectMeta: metav1.ObjectMeta{
    97  			Name:      fmt.Sprintf("%s-%s", kd.Name, pod.Name),
    98  			Namespace: kd.Namespace,
    99  			Annotations: map[string]string{
   100  				v1alpha1.AnnotationManifest: kd.Annotations[v1alpha1.AnnotationManifest],
   101  				v1alpha1.AnnotationSpanID:   kd.Annotations[v1alpha1.AnnotationSpanID],
   102  			},
   103  		},
   104  		Spec: v1alpha1.PortForwardSpec{
   105  			PodName:   pod.Name,
   106  			Namespace: pod.Namespace,
   107  			Forwards:  populateContainerPorts(pfTemplate, pod),
   108  			Cluster:   kd.Spec.Cluster,
   109  		},
   110  	}
   111  	err := controllerutil.SetControllerReference(kd, pf, r.ctrlClient.Scheme())
   112  	if err != nil {
   113  		return nil, err
   114  	}
   115  	return pf, nil
   116  }
   117  
   118  // If any of the port-forward specs have ContainerPort = 0, populate them with
   119  // the documented ports on the pod. If there's no default documented ports for
   120  // the pod, populate it with the local port.
   121  //
   122  // TODO(nick): This is old legacy behavior, and I'm not totally sure it even
   123  // makes sense. I wonder if we should just insist that ContainerPort is populated.
   124  func populateContainerPorts(pft *v1alpha1.PortForwardTemplateSpec, pod *v1alpha1.Pod) []v1alpha1.Forward {
   125  	result := make([]v1alpha1.Forward, len(pft.Forwards))
   126  
   127  	cPorts := store.AllPodContainerPorts(*pod)
   128  	for i := range pft.Forwards {
   129  		forward := pft.Forwards[i].DeepCopy()
   130  		if forward.ContainerPort == 0 && len(cPorts) > 0 {
   131  			forward.ContainerPort = cPorts[0]
   132  			for _, cPort := range cPorts {
   133  				if int(forward.LocalPort) == int(cPort) {
   134  					forward.ContainerPort = cPort
   135  					break
   136  				}
   137  			}
   138  		}
   139  		if forward.ContainerPort == 0 {
   140  			forward.ContainerPort = forward.LocalPort
   141  		}
   142  		result[i] = *forward
   143  	}
   144  	return result
   145  }
   146  
   147  // We can only portforward to one pod at a time.
   148  // So pick the "best" pod to portforward to.
   149  // May be nil if there is no eligible pod.
   150  func PickBestPortForwardPod(kd *v1alpha1.KubernetesDiscovery) *v1alpha1.Pod {
   151  	var bestPod *v1alpha1.Pod
   152  	for _, pod := range kd.Status.Pods {
   153  		pod := pod
   154  		if pod.Name == "" {
   155  			continue
   156  		}
   157  
   158  		// Only do port-forwarding if the pod is running or deleting.
   159  		isRunning := pod.Phase == string(v1.PodRunning)
   160  		isDeleting := pod.Deleting
   161  		if !isRunning && !isDeleting {
   162  			continue
   163  		}
   164  
   165  		// This pod is eligible! Now compare it to the existing candidate (if there is one).
   166  		if bestPod == nil || isBetterPortForwardPod(&pod, bestPod) {
   167  			bestPod = &pod
   168  		}
   169  	}
   170  	return bestPod
   171  }
   172  
   173  // Check if podA is better than podB for port-forwarding.
   174  func isBetterPortForwardPod(podA, podB *v1alpha1.Pod) bool {
   175  	// A non-deleting pod is always better than a deleting pod.
   176  	if podB.Deleting && !podA.Deleting {
   177  		return true
   178  	} else if podA.Deleting && !podB.Deleting {
   179  		return false
   180  	}
   181  
   182  	// Otherwise, a more recent pod is better.
   183  	if podA.CreatedAt.After(podB.CreatedAt.Time) {
   184  		return true
   185  	} else if podB.CreatedAt.After(podA.CreatedAt.Time) {
   186  		return false
   187  	}
   188  
   189  	// Use the name as a tie-breaker.
   190  	return podA.Name > podB.Name
   191  }
   192  
   193  func warnDeprecatedImplicitForwards(ctx context.Context, kd *v1alpha1.KubernetesDiscovery, pf *v1alpha1.PortForward) {
   194  	if kd == nil || pf == nil {
   195  		return
   196  	}
   197  	resourceName := kd.Annotations[v1alpha1.AnnotationManifest]
   198  	if resourceName == "" {
   199  		return
   200  	}
   201  
   202  	for _, pft := range kd.Spec.PortForwardTemplateSpec.Forwards {
   203  		if pft.ContainerPort != 0 {
   204  			continue
   205  		}
   206  
   207  		for _, f := range pf.Spec.Forwards {
   208  			if pft.LocalPort == f.LocalPort && f.LocalPort != f.ContainerPort {
   209  				logger.Get(ctx).Warnf(
   210  					"k8s_resource(name='%s', port_forwards='%d') currently maps localhost:%d to port %d in your container.\n"+
   211  						"A future version of Tilt will change this default and will map localhost:%d to port %d in your container.\n"+
   212  						"To keep your project working, change your Tiltfile to k8s_resource(name='%s', port_forwards='%d:%d')",
   213  					resourceName,    // name=%s
   214  					f.LocalPort,     // port_forward=%d
   215  					f.LocalPort,     // localhost:%d
   216  					f.ContainerPort, // to port %d (deprecated)
   217  					f.LocalPort,     // localhost:%d
   218  					f.LocalPort,     // to port %d (new)
   219  					resourceName,    // name=%s
   220  					f.LocalPort,     // port_forward='%d:x'
   221  					f.ContainerPort, // port_forward='x:%d'
   222  				)
   223  			}
   224  		}
   225  	}
   226  }