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 }