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  }