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

     1  package portforward
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"strings"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/davecgh/go-spew/spew"
    12  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    13  
    14  	"github.com/stretchr/testify/require"
    15  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    16  	"k8s.io/apimachinery/pkg/types"
    17  	ctrl "sigs.k8s.io/controller-runtime"
    18  
    19  	"github.com/tilt-dev/tilt/internal/controllers/apis/cluster"
    20  	"github.com/tilt-dev/tilt/internal/controllers/fake"
    21  	"github.com/tilt-dev/tilt/pkg/apis"
    22  
    23  	"github.com/tilt-dev/tilt/pkg/model"
    24  
    25  	"github.com/tilt-dev/tilt/internal/store/k8sconv"
    26  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    27  
    28  	"github.com/stretchr/testify/assert"
    29  
    30  	"github.com/tilt-dev/tilt/internal/k8s"
    31  	"github.com/tilt-dev/tilt/internal/store"
    32  )
    33  
    34  const (
    35  	pfFooName = "pf_foo"
    36  	pfBarName = "pf_bar"
    37  )
    38  
    39  func TestCreatePortForward(t *testing.T) {
    40  	f := newPFRFixture(t)
    41  
    42  	require.Equal(t, 0, len(f.r.activeForwards))
    43  
    44  	pf := f.makeSimplePF(pfFooName, 8000, 8080)
    45  	f.Create(pf)
    46  	kCli := f.clients.MustK8sClient(clusterNN(pf))
    47  
    48  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
    49  	require.Equal(t, 1, len(f.r.activeForwards))
    50  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
    51  	assert.Equal(t, 8080, kCli.LastForwardPortRemotePort())
    52  }
    53  
    54  func TestDeletePortForward(t *testing.T) {
    55  	f := newPFRFixture(t)
    56  
    57  	require.Equal(t, 0, len(f.r.activeForwards))
    58  
    59  	pf := f.makeSimplePF(pfFooName, 8000, 8080)
    60  	f.Create(pf)
    61  	kCli := f.clients.MustK8sClient(clusterNN(pf))
    62  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
    63  
    64  	require.Equal(t, 1, len(f.r.activeForwards))
    65  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
    66  	assert.Equal(t, 8080, kCli.LastForwardPortRemotePort())
    67  	origForwardCtx := kCli.LastForwardContext()
    68  
    69  	f.Delete(pf)
    70  	f.requirePortForwardDeleted(pfFooName)
    71  
    72  	require.Equal(t, 0, len(f.r.activeForwards))
    73  	f.assertContextCancelled(t, origForwardCtx)
    74  }
    75  
    76  func TestModifyPortForward(t *testing.T) {
    77  	f := newPFRFixture(t)
    78  
    79  	require.Equal(t, 0, len(f.r.activeForwards))
    80  
    81  	pf := f.makeSimplePF(pfFooName, 8000, 8080)
    82  	f.Create(pf)
    83  	kCli := f.clients.MustK8sClient(clusterNN(pf))
    84  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
    85  
    86  	require.Equal(t, 1, len(f.r.activeForwards))
    87  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
    88  	assert.Equal(t, 8080, kCli.LastForwardPortRemotePort())
    89  	origForwardCtx := kCli.LastForwardContext()
    90  
    91  	pf = f.makeSimplePF(pfFooName, 8001, 9090)
    92  	f.GetAndUpdate(pf)
    93  	f.requirePortForwardStarted(pfFooName, 8001, 9090)
    94  
    95  	require.Equal(t, 1, len(f.r.activeForwards))
    96  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
    97  	assert.Equal(t, 9090, kCli.LastForwardPortRemotePort())
    98  
    99  	f.assertContextCancelled(t, origForwardCtx)
   100  }
   101  
   102  func TestModifyPortForwardManifestName(t *testing.T) {
   103  	// A change to only the manifestName should be enough to tear down and recreate
   104  	// a PortForward (we need to do this so the logs will be routed correctly)
   105  	f := newPFRFixture(t)
   106  
   107  	require.Equal(t, 0, len(f.r.activeForwards))
   108  
   109  	fwds := []v1alpha1.Forward{f.makeForward(8000, 8080, "")}
   110  
   111  	pf := f.makePF(pfFooName, "manifestA", "pod-pf_foo", "", fwds)
   112  	f.Create(pf)
   113  	kCli := f.clients.MustK8sClient(clusterNN(pf))
   114  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   115  
   116  	require.Equal(t, 1, len(f.r.activeForwards))
   117  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
   118  	assert.Equal(t, 8080, kCli.LastForwardPortRemotePort())
   119  	origForwardCtx := kCli.LastForwardContext()
   120  
   121  	pf = f.makePF(pfFooName, "manifestB", "pod-pf_foo", "", fwds)
   122  	f.GetAndUpdate(pf)
   123  	f.requireState(pfFooName, func(pf *PortForward) bool {
   124  		return pf != nil && pf.ObjectMeta.Annotations[v1alpha1.AnnotationManifest] == "manifestB"
   125  	}, "Manifest annotation was not updated")
   126  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   127  
   128  	require.Equal(t, 1, len(f.r.activeForwards))
   129  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
   130  	assert.Equal(t, 8080, kCli.LastForwardPortRemotePort())
   131  
   132  	f.assertContextCancelled(t, origForwardCtx)
   133  }
   134  
   135  func TestMultipleForwardsForOnePod(t *testing.T) {
   136  	f := newPFRFixture(t)
   137  
   138  	require.Equal(t, 0, len(f.r.activeForwards))
   139  
   140  	forwards := []v1alpha1.Forward{
   141  		f.makeForward(8000, 8080, "hostA"),
   142  		f.makeForward(8001, 8081, "hostB"),
   143  	}
   144  
   145  	pf := f.makeSimplePFMultipleForwards(pfFooName, forwards)
   146  	f.Create(pf)
   147  	kCli := f.clients.MustK8sClient(clusterNN(pf))
   148  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   149  	f.requirePortForwardStarted(pfFooName, 8001, 8081)
   150  
   151  	require.Equal(t, 1, len(f.r.activeForwards))
   152  	require.Equal(t, 2, kCli.CreatePortForwardCallCount())
   153  
   154  	var seen8080, seen8081 bool
   155  	var contexts []context.Context
   156  	for _, call := range kCli.PortForwardCalls() {
   157  		assert.Equal(t, "pod-pf_foo", call.PodID.String())
   158  		switch call.RemotePort {
   159  		case 8080:
   160  			seen8080 = true
   161  			contexts = append(contexts, call.Context)
   162  			assert.Equal(t, "hostA", call.Host, "unexpected host for port forward to 8080")
   163  		case 8081:
   164  			seen8081 = true
   165  			contexts = append(contexts, call.Context)
   166  			assert.Equal(t, "hostB", call.Host, "unexpected host for port forward to 8081")
   167  		default:
   168  			t.Fatalf("found port forward call to unexpected remotePort: %+v", call)
   169  		}
   170  	}
   171  	require.True(t, seen8080, "did not see port forward to remotePort 8080")
   172  	require.True(t, seen8081, "did not see port forward to remotePort 8081")
   173  
   174  	f.Delete(pf)
   175  	f.requirePortForwardDeleted(pfFooName)
   176  
   177  	require.Equal(t, 0, len(f.r.activeForwards))
   178  	for _, ctx := range contexts {
   179  		f.assertContextCancelled(t, ctx)
   180  	}
   181  }
   182  
   183  func TestMultipleForwardsMultiplePods(t *testing.T) {
   184  	f := newPFRFixture(t)
   185  
   186  	require.Equal(t, 0, len(f.r.activeForwards))
   187  
   188  	fwdsFoo := []v1alpha1.Forward{f.makeForward(8000, 8080, "host-foo")}
   189  	fwdsBar := []v1alpha1.Forward{f.makeForward(8001, 8081, "host-bar")}
   190  	pfFoo := f.makePF(pfFooName, "foo", "pod-pf_foo", "ns-foo", fwdsFoo)
   191  	pfBar := f.makePF(pfBarName, "bar", "pod-pf_bar", "ns-bar", fwdsBar)
   192  	f.Create(pfFoo)
   193  	f.Create(pfBar)
   194  	kCli := f.clients.MustK8sClient(clusterNN(pfFoo))
   195  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   196  	f.requirePortForwardStarted(pfBarName, 8001, 8081)
   197  
   198  	require.Equal(t, 2, len(f.r.activeForwards))
   199  	require.Equal(t, 2, kCli.CreatePortForwardCallCount())
   200  
   201  	// PortForwards are executed async so we can't guarantee the order;
   202  	// just make sure each expected call appears exactly once
   203  	var seenFoo, seenBar bool
   204  	var ctxFoo, ctxBar context.Context
   205  	for _, call := range kCli.PortForwardCalls() {
   206  		if call.PodID.String() == "pod-pf_foo" {
   207  			seenFoo = true
   208  			ctxFoo = call.Context
   209  			assert.Equal(t, 8080, call.RemotePort, "remotePort for forward foo")
   210  			assert.Equal(t, "ns-foo", call.Forwarder.Namespace().String(), "namespace for forward foo")
   211  			assert.Equal(t, "host-foo", call.Host, "host for forward foo")
   212  		} else if call.PodID.String() == "pod-pf_bar" {
   213  			seenBar = true
   214  			ctxBar = call.Context
   215  			assert.Equal(t, 8081, call.RemotePort, "remotePort for forward bar")
   216  			assert.Equal(t, "ns-bar", call.Forwarder.Namespace().String(), "namespace for forward bar")
   217  			assert.Equal(t, "host-bar", call.Host, "host for forward bar")
   218  		} else {
   219  			t.Fatalf("found port forward call for unexpected pod: %+v", call)
   220  		}
   221  	}
   222  	require.True(t, seenFoo, "did not see port forward foo")
   223  	require.True(t, seenBar, "did not see port forward bar")
   224  
   225  	f.Delete(pfFoo)
   226  	f.requirePortForwardDeleted(pfFooName)
   227  
   228  	require.Equal(t, 1, len(f.r.activeForwards))
   229  	f.assertContextCancelled(t, ctxFoo)
   230  	f.assertContextNotCancelled(t, ctxBar)
   231  }
   232  
   233  func TestPortForwardStartFailure(t *testing.T) {
   234  	f := newPFRFixture(t)
   235  
   236  	require.Equal(t, 0, len(f.r.activeForwards))
   237  
   238  	pf := f.makeSimplePF(pfFooName, k8s.MagicTestExplodingPort, 8080)
   239  	f.Create(pf)
   240  
   241  	f.requirePortForwardError(pfFooName, k8s.MagicTestExplodingPort, 8080,
   242  		"fake error starting port forwarding")
   243  }
   244  
   245  func TestPortForwardRuntimeFailure(t *testing.T) {
   246  	f := newPFRFixture(t)
   247  
   248  	require.Equal(t, 0, len(f.r.activeForwards))
   249  
   250  	pf := f.makeSimplePF(pfFooName, 8000, 8080)
   251  	f.Create(pf)
   252  	// wait for port forward to be successful
   253  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   254  
   255  	kCli := f.clients.MustK8sClient(clusterNN(pf))
   256  	const errMsg = "fake runtime port forwarding error"
   257  	kCli.LastForwarder().TriggerFailure(errors.New(errMsg))
   258  
   259  	f.requirePortForwardError(pfFooName, 8000, 8080, errMsg)
   260  }
   261  
   262  func TestPortForwardPartialSuccess(t *testing.T) {
   263  	f := newPFRFixture(t)
   264  
   265  	require.Equal(t, 0, len(f.r.activeForwards))
   266  
   267  	forwards := []Forward{
   268  		f.makeForward(8000, 8080, "localhost"),
   269  		f.makeForward(8001, 8081, "localhost"),
   270  		f.makeForward(k8s.MagicTestExplodingPort, 8082, "localhost"),
   271  	}
   272  
   273  	pf := f.makeSimplePFMultipleForwards(pfFooName, forwards)
   274  	f.Create(pf)
   275  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   276  	f.requirePortForwardStarted(pfFooName, 8001, 8081)
   277  	f.requirePortForwardError(pfFooName, k8s.MagicTestExplodingPort, 8082, "fake error starting port forwarding")
   278  
   279  	kCli := f.clients.MustK8sClient(clusterNN(pf))
   280  	const errMsg = "fake runtime port forwarding error"
   281  	for _, pfCall := range kCli.PortForwardCalls() {
   282  		if pfCall.RemotePort == 8080 {
   283  			pfCall.Forwarder.TriggerFailure(errors.New(errMsg))
   284  		}
   285  	}
   286  
   287  	f.requirePortForwardError(pfFooName, 8000, 8080, errMsg)
   288  	f.requirePortForwardStarted(pfFooName, 8001, 8081)
   289  	f.requirePortForwardError(pfFooName, k8s.MagicTestExplodingPort, 8082, "fake error starting port forwarding")
   290  }
   291  
   292  func TestIndexing(t *testing.T) {
   293  	f := newPFRFixture(t)
   294  
   295  	pf := f.makeSimplePF(pfFooName, 8000, 8080)
   296  	f.Create(pf)
   297  	f.MustGet(apis.Key(pf), pf)
   298  
   299  	ctx := context.Background()
   300  	reqs := f.r.indexer.Enqueue(ctx, &v1alpha1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "default"}})
   301  	require.ElementsMatch(t, []reconcile.Request{
   302  		{NamespacedName: types.NamespacedName{Name: pfFooName}},
   303  	}, reqs)
   304  
   305  	reqs = f.r.indexer.Enqueue(ctx, &v1alpha1.Cluster{ObjectMeta: metav1.ObjectMeta{Name: "other"}})
   306  	require.Empty(t, reqs)
   307  }
   308  
   309  func TestClusterChange(t *testing.T) {
   310  	f := newPFRFixture(t)
   311  
   312  	require.Equal(t, 0, len(f.r.activeForwards))
   313  
   314  	pf := f.makeSimplePF(pfFooName, 8000, 8080)
   315  	f.Create(pf)
   316  	clusterKey := clusterNN(pf)
   317  
   318  	// port forward should be started and have made a call to fake client
   319  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   320  	require.Equal(t, 1, len(f.r.activeForwards))
   321  	assert.Equal(t, "pod-pf_foo",
   322  		f.clients.MustK8sClient(clusterKey).LastForwardPortPodID().String())
   323  
   324  	// put the cluster into an error state and verify that active forward(s)
   325  	// are stopped
   326  	f.clients.EnsureK8sClusterError(f.Context(), clusterKey, errors.New("oh no"))
   327  	_, err := f.Reconcile(apis.Key(pf))
   328  	require.EqualError(t, err, "oh no")
   329  	require.Empty(t, len(f.r.activeForwards),
   330  		"Port forward should have been stopped")
   331  
   332  	// create a new healthy client and verify that it gets used
   333  	kCli, _ := f.clients.EnsureK8sCluster(f.Context(), clusterKey)
   334  	require.Zero(t, kCli.CreatePortForwardCallCount(),
   335  		"No port forwards should exist")
   336  	f.MustReconcile(apis.Key(pf))
   337  	f.requirePortForwardStarted(pfFooName, 8000, 8080)
   338  	require.Equal(t, 1, len(f.r.activeForwards))
   339  	assert.Equal(t, "pod-pf_foo", kCli.LastForwardPortPodID().String())
   340  	assert.Equal(t, 8080, kCli.LastForwardPortRemotePort())
   341  }
   342  
   343  type pfrFixture struct {
   344  	*fake.ControllerFixture
   345  	t       *testing.T
   346  	st      store.RStore
   347  	r       *Reconciler
   348  	clients *cluster.FakeClientProvider
   349  }
   350  
   351  func newPFRFixture(t *testing.T) *pfrFixture {
   352  	cfb := fake.NewControllerFixtureBuilder(t)
   353  	clients := cluster.NewFakeClientProvider(t, cfb.Client)
   354  	r := NewReconciler(cfb.Client, cfb.Scheme(), cfb.Store, clients)
   355  
   356  	return &pfrFixture{
   357  		ControllerFixture: cfb.WithRequeuer(r.requeuer).Build(r),
   358  		t:                 t,
   359  		st:                cfb.Store,
   360  		r:                 r,
   361  		clients:           clients,
   362  	}
   363  }
   364  
   365  func (f *pfrFixture) requireState(name string, cond func(pf *PortForward) bool, msg string, args ...interface{}) {
   366  	f.t.Helper()
   367  	key := types.NamespacedName{Name: name}
   368  	require.Eventuallyf(f.t, func() bool {
   369  		var pf PortForward
   370  		if !f.Get(key, &pf) {
   371  			return cond(nil)
   372  		}
   373  		return cond(&pf)
   374  	}, 2*time.Second, 20*time.Millisecond, msg, args...)
   375  }
   376  
   377  func (f *pfrFixture) requirePortForwardStatus(name string, localPort, containerPort int32, cond func(ForwardStatus) (bool, string)) {
   378  	f.t.Helper()
   379  	var desc strings.Builder
   380  	f.requireState(name, func(pf *PortForward) bool {
   381  		desc.Reset()
   382  		if pf == nil {
   383  			desc.WriteString("object does not exist in api")
   384  			return false
   385  		}
   386  		for _, f := range pf.Status.ForwardStatuses {
   387  			if f.LocalPort != localPort || f.ContainerPort != containerPort {
   388  				continue
   389  			}
   390  			ok, msg := cond(*f.DeepCopy())
   391  			desc.WriteString(msg)
   392  			return ok
   393  		}
   394  		desc.WriteString("did not find matching forward status for ports:\n")
   395  		desc.WriteString(spew.Sdump(pf.Status.ForwardStatuses))
   396  		return false
   397  	}, "PortForward %q status for localPort=%d / containerPort=%d did not match condition: %s", name, localPort, containerPort, &desc)
   398  }
   399  
   400  func (f *pfrFixture) requirePortForwardError(name string, localPort, containerPort int32, errMsg string) {
   401  	f.t.Helper()
   402  	f.requirePortForwardStatus(name, localPort, containerPort, func(status ForwardStatus) (bool, string) {
   403  		if !strings.Contains(status.Error, errMsg) {
   404  			return false, fmt.Sprintf("error %q does not contain %q", status.Error, errMsg)
   405  		}
   406  		return true, ""
   407  	})
   408  }
   409  
   410  func (f *pfrFixture) requirePortForwardStarted(name string, localPort int32, containerPort int32) {
   411  	f.t.Helper()
   412  	f.requirePortForwardStatus(name, localPort, containerPort, func(status ForwardStatus) (bool, string) {
   413  		if status.StartedAt.IsZero() || status.Error != "" {
   414  			return false, fmt.Sprintf("status has startedAt=%s / error=%q", status.StartedAt.String(), status.Error)
   415  		}
   416  		return true, ""
   417  	})
   418  }
   419  
   420  func (f *pfrFixture) requirePortForwardDeleted(name string) {
   421  	f.t.Helper()
   422  	f.requireState(name, func(pf *PortForward) bool {
   423  		return pf == nil
   424  	}, "port forward deleted")
   425  }
   426  
   427  // GetAndUpdate pulls the existing version of the PortForward and issues an
   428  // update (using the ResourceVersion of the existing Port Forward to avoid an
   429  // "object was modified" error)
   430  func (f *pfrFixture) GetAndUpdate(pf *PortForward) ctrl.Result {
   431  	f.t.Helper()
   432  	var existing PortForward
   433  	f.MustGet(f.KeyForObject(pf), &existing)
   434  	pf.SetResourceVersion(existing.GetResourceVersion())
   435  	require.NoError(f.t, f.Client.Update(f.Context(), pf))
   436  	return f.MustReconcile(f.KeyForObject(pf))
   437  }
   438  
   439  func (f *pfrFixture) Create(pf *v1alpha1.PortForward) ctrl.Result {
   440  	f.t.Helper()
   441  	f.ensureCluster(pf)
   442  	return f.ControllerFixture.Create(pf)
   443  }
   444  
   445  func (f *pfrFixture) assertContextCancelled(t *testing.T, ctx context.Context) {
   446  	if assert.Error(t, ctx.Err(), "expect cancelled context to have a non-nil error") {
   447  		assert.Equal(t, context.Canceled, ctx.Err(), "expect context to be cancelled")
   448  	}
   449  }
   450  
   451  func (f *pfrFixture) assertContextNotCancelled(t *testing.T, ctx context.Context) {
   452  	assert.NoError(t, ctx.Err(), "expect non-cancelled context to have no error")
   453  }
   454  
   455  func (f *pfrFixture) makePF(name string, mName model.ManifestName, podName k8s.PodID, ns string, forwards []Forward) *PortForward {
   456  	return &PortForward{
   457  		ObjectMeta: metav1.ObjectMeta{
   458  			Name: name,
   459  			Annotations: map[string]string{
   460  				v1alpha1.AnnotationManifest: mName.String(),
   461  				v1alpha1.AnnotationSpanID:   string(k8sconv.SpanIDForPod(mName, podName)),
   462  			},
   463  		},
   464  		Spec: PortForwardSpec{
   465  			PodName:   podName.String(),
   466  			Namespace: ns,
   467  			Forwards:  forwards,
   468  		},
   469  	}
   470  }
   471  
   472  func (f *pfrFixture) makeSimplePF(name string, localPort, containerPort int32) *PortForward {
   473  	fwd := Forward{
   474  		LocalPort:     localPort,
   475  		ContainerPort: containerPort,
   476  	}
   477  	return f.makeSimplePFMultipleForwards(name, []Forward{fwd})
   478  }
   479  
   480  func (f *pfrFixture) makeSimplePFMultipleForwards(name string, forwards []Forward) *PortForward {
   481  	return f.makePF(name, model.ManifestName(fmt.Sprintf("manifest-%s", name)), k8s.PodID(fmt.Sprintf("pod-%s", name)), "", forwards)
   482  }
   483  
   484  func (f *pfrFixture) makeForward(localPort, containerPort int32, host string) Forward {
   485  	return Forward{
   486  		LocalPort:     localPort,
   487  		ContainerPort: containerPort,
   488  		Host:          host,
   489  	}
   490  }
   491  
   492  func (f *pfrFixture) ensureCluster(pf *v1alpha1.PortForward) {
   493  	f.t.Helper()
   494  	pf = pf.DeepCopy()
   495  	pf.Default()
   496  	f.clients.EnsureK8sCluster(f.Context(), clusterNN(pf))
   497  }