github.com/tilt-dev/tilt@v0.36.0/internal/controllers/core/cluster/reconciler_test.go (about) 1 package cluster 2 3 import ( 4 "errors" 5 "os" 6 "path/filepath" 7 "testing" 8 "time" 9 10 "github.com/google/go-cmp/cmp" 11 "github.com/jonboulle/clockwork" 12 "github.com/spf13/afero" 13 "github.com/stretchr/testify/assert" 14 "github.com/stretchr/testify/require" 15 v1 "k8s.io/api/core/v1" 16 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 "k8s.io/apimachinery/pkg/types" 18 19 "github.com/tilt-dev/tilt/internal/hud/server" 20 "github.com/tilt-dev/tilt/internal/k8s/kubeconfig" 21 "github.com/tilt-dev/tilt/internal/localexec" 22 "github.com/tilt-dev/tilt/internal/testutils/tempdir" 23 "github.com/tilt-dev/tilt/internal/xdg" 24 "github.com/tilt-dev/wmclient/pkg/analytics" 25 26 "github.com/tilt-dev/tilt/internal/controllers/apicmp" 27 "github.com/tilt-dev/tilt/internal/controllers/fake" 28 "github.com/tilt-dev/tilt/internal/controllers/indexer" 29 "github.com/tilt-dev/tilt/internal/docker" 30 "github.com/tilt-dev/tilt/internal/k8s" 31 "github.com/tilt-dev/tilt/internal/timecmp" 32 "github.com/tilt-dev/tilt/pkg/apis" 33 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 34 ) 35 36 func TestKubernetesError(t *testing.T) { 37 f := newFixture(t) 38 cluster := &v1alpha1.Cluster{ 39 ObjectMeta: metav1.ObjectMeta{Name: "default"}, 40 Spec: v1alpha1.ClusterSpec{ 41 Connection: &v1alpha1.ClusterConnection{ 42 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 43 }, 44 }, 45 } 46 nn := apis.Key(cluster) 47 48 // Create a fake client factory that always returns an error 49 origClientFactory := f.r.k8sClientFactory 50 f.r.k8sClientFactory = FakeKubernetesClientOrError(nil, errors.New("fake error")) 51 f.Create(cluster) 52 53 assert.Equal(t, "", cluster.Status.Error) 54 f.MustGet(nn, cluster) 55 assert.Equal(t, 56 "Tilt encountered an error connecting to your Kubernetes cluster:\n\tfake error\nYou will need to restart Tilt after resolving the issue.", 57 cluster.Status.Error) 58 assert.Nil(t, cluster.Status.ConnectedAt, "ConnectedAt should be empty") 59 60 // replace the working client factory but ensure that it's not invoked 61 // we should be in a steady state until the retry/backoff window elapses 62 f.r.k8sClientFactory = origClientFactory 63 f.assertSteadyState(cluster) 64 65 // advance the clock such that we should retry, but ensure that no retry 66 // is attempted because the cluster refresh feature flag annotation is 67 // not set 68 f.clock.Advance(time.Minute) 69 f.assertSteadyState(cluster) 70 71 // add the cluster refresh feature flag and verify that it gets refreshed 72 // and creates a new client without errors 73 if cluster.Annotations == nil { 74 cluster.Annotations = make(map[string]string) 75 } 76 cluster.Annotations["features.tilt.dev/cluster-refresh"] = "true" 77 f.Update(cluster) 78 79 f.MustGet(nn, cluster) 80 require.Empty(t, cluster.Status.Error, "No error should be present on cluster") 81 if assert.NotNil(t, cluster.Status.ConnectedAt, "ConnectedAt should be populated") { 82 assert.NotZero(t, cluster.Status.ConnectedAt.Time, "ConnectedAt should not be zero time") 83 } 84 } 85 86 func TestKubernetesDelete(t *testing.T) { 87 f := newFixture(t) 88 cluster := &v1alpha1.Cluster{ 89 ObjectMeta: metav1.ObjectMeta{Name: "default"}, 90 Spec: v1alpha1.ClusterSpec{ 91 Connection: &v1alpha1.ClusterConnection{ 92 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 93 }, 94 }, 95 } 96 nn := apis.Key(cluster) 97 98 f.Create(cluster) 99 _, ok := f.r.connManager.load(nn) 100 require.True(t, ok, "Connection was not present in connection manager") 101 102 f.Delete(cluster) 103 _, ok = f.r.connManager.load(nn) 104 require.False(t, ok, "Connection was not removed from connection manager") 105 } 106 107 func TestKubernetesArch(t *testing.T) { 108 f := newFixture(t) 109 cluster := &v1alpha1.Cluster{ 110 ObjectMeta: metav1.ObjectMeta{Name: "default"}, 111 Spec: v1alpha1.ClusterSpec{ 112 Connection: &v1alpha1.ClusterConnection{ 113 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 114 }, 115 }, 116 } 117 118 // Inject a Node into the fake client so that the arch can be determined. 119 nn := types.NamespacedName{Name: "default"} 120 f.k8sClient.Inject(k8s.K8sEntity{ 121 Obj: &v1.Node{ 122 TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Node"}, 123 ObjectMeta: metav1.ObjectMeta{ 124 Name: "node-1", 125 UID: "a", 126 Labels: map[string]string{ 127 "kubernetes.io/arch": "amd64", 128 }, 129 }, 130 }, 131 }) 132 133 f.Create(cluster) 134 f.MustGet(nn, cluster) 135 assert.Equal(t, "amd64", cluster.Status.Arch) 136 137 f.assertSteadyState(cluster) 138 139 connectEvt := analytics.CountEvent{ 140 Name: "api.cluster.connect", 141 Tags: map[string]string{ 142 "type": "kubernetes", 143 "arch": "amd64", 144 "status": "connected", 145 }, 146 N: 1, 147 } 148 assert.ElementsMatch(t, []analytics.CountEvent{connectEvt}, f.ma.Counts) 149 } 150 151 func TestKubernetesConnStatus(t *testing.T) { 152 f := newFixture(t) 153 cluster := &v1alpha1.Cluster{ 154 ObjectMeta: metav1.ObjectMeta{Name: "c"}, 155 Spec: v1alpha1.ClusterSpec{ 156 Connection: &v1alpha1.ClusterConnection{ 157 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 158 }, 159 }, 160 } 161 162 nn := types.NamespacedName{Name: "c"} 163 f.Create(cluster) 164 f.MustGet(nn, cluster) 165 166 configPath := cluster.Status.Connection.Kubernetes.ConfigPath 167 require.NotEqual(t, configPath, "") 168 169 expected := &v1alpha1.ClusterConnectionStatus{ 170 Kubernetes: &v1alpha1.KubernetesClusterConnectionStatus{ 171 Context: "default", 172 Namespace: "default", 173 Cluster: "default", 174 Product: "unknown", 175 ConfigPath: configPath, 176 }, 177 } 178 assert.Equal(t, expected, cluster.Status.Connection) 179 180 contents, err := afero.ReadFile(f.fs, configPath) 181 require.NoError(t, err) 182 assert.Equal(t, `apiVersion: v1 183 clusters: 184 - cluster: 185 server: "" 186 name: default 187 contexts: 188 - context: 189 cluster: default 190 namespace: default 191 user: "" 192 name: default 193 current-context: default 194 kind: Config 195 users: null 196 `, string(contents)) 197 } 198 199 func TestKubernetesMonitor(t *testing.T) { 200 f := newFixture(t) 201 cluster := &v1alpha1.Cluster{ 202 ObjectMeta: metav1.ObjectMeta{Name: "default"}, 203 Spec: v1alpha1.ClusterSpec{ 204 Connection: &v1alpha1.ClusterConnection{ 205 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 206 }, 207 }, 208 } 209 nn := apis.Key(cluster) 210 211 f.Create(cluster) 212 f.MustGet(nn, cluster) 213 connectedAt := *cluster.Status.ConnectedAt 214 f.assertSteadyState(cluster) 215 216 f.k8sClient.ClusterHealthError = errors.New("fake cluster health error") 217 f.clock.Advance(time.Minute) 218 <-f.requeues 219 220 f.MustGet(nn, cluster) 221 assert.Equal(t, "fake cluster health error", cluster.Status.Error) 222 timecmp.RequireTimeEqual(t, connectedAt, cluster.Status.ConnectedAt) 223 } 224 225 func TestDockerError(t *testing.T) { 226 f := newFixture(t) 227 cluster := &v1alpha1.Cluster{ 228 ObjectMeta: metav1.ObjectMeta{Name: "default"}, 229 Spec: v1alpha1.ClusterSpec{ 230 Connection: &v1alpha1.ClusterConnection{ 231 Docker: &v1alpha1.DockerClusterConnection{}, 232 }, 233 }, 234 } 235 nn := apis.Key(cluster) 236 237 f.r.dockerClientFactory = FakeDockerClientOrError(nil, errors.New("fake docker error")) 238 239 f.Create(cluster) 240 f.MustGet(nn, cluster) 241 assert.Equal(t, "fake docker error", cluster.Status.Error) 242 assert.Nil(t, cluster.Status.ConnectedAt, "ConnectedAt should not be populated") 243 assert.Empty(t, cluster.Status.Arch, "no arch should be present") 244 } 245 246 func TestDockerArch(t *testing.T) { 247 f := newFixture(t) 248 cluster := &v1alpha1.Cluster{ 249 ObjectMeta: metav1.ObjectMeta{Name: "default"}, 250 Spec: v1alpha1.ClusterSpec{ 251 Connection: &v1alpha1.ClusterConnection{ 252 Docker: &v1alpha1.DockerClusterConnection{}, 253 }, 254 }, 255 } 256 257 nn := types.NamespacedName{Name: "default"} 258 f.Create(cluster) 259 f.MustGet(nn, cluster) 260 assert.Equal(t, "amd64", cluster.Status.Arch) 261 if assert.NotNil(t, cluster.Status.ConnectedAt, "ConnectedAt should be populated") { 262 assert.NotZero(t, cluster.Status.ConnectedAt.Time, "ConnectedAt should not be zero") 263 } 264 } 265 266 func TestKubeconfig_RuntimeDirImmutable(t *testing.T) { 267 f := newFixture(t) 268 cluster := &v1alpha1.Cluster{ 269 ObjectMeta: metav1.ObjectMeta{Name: "c"}, 270 Spec: v1alpha1.ClusterSpec{ 271 Connection: &v1alpha1.ClusterConnection{ 272 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 273 }, 274 }, 275 } 276 277 p, err := f.base.RuntimeFile(filepath.Join("tilt-default", "cluster", "c.yml")) 278 require.NoError(t, err) 279 runtimeFile, _ := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0400) 280 _ = runtimeFile.Close() 281 282 nn := types.NamespacedName{Name: "c"} 283 f.Create(cluster) 284 f.MustGet(nn, cluster) 285 286 configPath := cluster.Status.Connection.Kubernetes.ConfigPath 287 require.NotEqual(t, configPath, "") 288 } 289 290 func TestKubeconfig_RuntimeAndStateDirImmutable(t *testing.T) { 291 f := newFixture(t) 292 cluster := &v1alpha1.Cluster{ 293 ObjectMeta: metav1.ObjectMeta{Name: "c"}, 294 Spec: v1alpha1.ClusterSpec{ 295 Connection: &v1alpha1.ClusterConnection{ 296 Kubernetes: &v1alpha1.KubernetesClusterConnection{}, 297 }, 298 }, 299 } 300 301 p, err := f.base.RuntimeFile(filepath.Join("tilt-default", "cluster", "c.yml")) 302 require.NoError(t, err) 303 runtimeFile, _ := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0400) 304 _ = runtimeFile.Close() 305 306 p, err = f.base.StateFile(filepath.Join("tilt-default", "cluster", "c.yml")) 307 require.NoError(t, err) 308 stateFile, _ := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0400) 309 _ = stateFile.Close() 310 311 nn := types.NamespacedName{Name: "c"} 312 f.Create(cluster) 313 f.MustGet(nn, cluster) 314 315 require.Equal(t, cluster.Status.Connection.Kubernetes.ConfigPath, "") 316 require.Contains(t, cluster.Status.Error, "storing temp kubeconfigs") 317 } 318 319 type fixture struct { 320 *fake.ControllerFixture 321 r *Reconciler 322 ma *analytics.MemoryAnalytics 323 clock *clockwork.FakeClock 324 k8sClient *k8s.FakeK8sClient 325 dockerClient *docker.FakeClient 326 base *xdg.FakeBase 327 requeues <-chan indexer.RequeueForTestResult 328 fs afero.Fs 329 } 330 331 func newFixture(t *testing.T) *fixture { 332 cfb := fake.NewControllerFixtureBuilder(t) 333 clock := clockwork.NewFakeClock() 334 tmpf := tempdir.NewTempDirFixture(t) 335 336 k8sClient := k8s.NewFakeK8sClient(t) 337 dockerClient := docker.NewFakeClient() 338 fs := afero.NewOsFs() 339 base := xdg.NewFakeBase(tmpf.Path(), fs) 340 kubeconfigWriter := kubeconfig.NewWriter(base, fs, "tilt-default") 341 localKubeconfigPathOnce := localexec.KubeconfigPathOnce(func() string { 342 return "/path/to/kubeconfig-default.yaml" 343 }) 344 r := NewReconciler(cfb.Context(), 345 cfb.Client, 346 cfb.Store, 347 clock, 348 NewConnectionManager(), 349 docker.LocalEnv{}, 350 FakeDockerClientOrError(dockerClient, nil), 351 FakeKubernetesClientOrError(k8sClient, nil), 352 server.NewWebsocketList(), 353 kubeconfigWriter, 354 localKubeconfigPathOnce) 355 requeueChan := make(chan indexer.RequeueForTestResult, 1) 356 return &fixture{ 357 ControllerFixture: cfb.WithRequeuer(r.requeuer).WithRequeuerResultChan(requeueChan).Build(r), 358 r: r, 359 ma: cfb.Analytics(), 360 clock: clock, 361 k8sClient: k8sClient, 362 dockerClient: dockerClient, 363 requeues: requeueChan, 364 base: base, 365 fs: fs, 366 } 367 } 368 369 func (f *fixture) assertSteadyState(o *v1alpha1.Cluster) { 370 f.T().Helper() 371 f.MustReconcile(types.NamespacedName{Name: o.Name}) 372 var o2 v1alpha1.Cluster 373 f.MustGet(types.NamespacedName{Name: o.Name}, &o2) 374 assert.True(f.T(), apicmp.DeepEqual(o, &o2), 375 "Cluster object should have been in steady state but changed: %s", 376 cmp.Diff(o, &o2)) 377 }