github.com/tilt-dev/tilt@v0.36.0/internal/controllers/core/cluster/reconciler.go (about)

     1  package cluster
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"time"
     7  
     8  	"github.com/docker/docker/client"
     9  	"github.com/jonboulle/clockwork"
    10  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    11  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    12  	"k8s.io/apimachinery/pkg/runtime/schema"
    13  	"k8s.io/apimachinery/pkg/types"
    14  	ctrl "sigs.k8s.io/controller-runtime"
    15  	"sigs.k8s.io/controller-runtime/pkg/builder"
    16  	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
    17  	"sigs.k8s.io/controller-runtime/pkg/reconcile"
    18  
    19  	"github.com/tilt-dev/tilt/internal/analytics"
    20  	"github.com/tilt-dev/tilt/internal/container"
    21  	"github.com/tilt-dev/tilt/internal/controllers/apicmp"
    22  	"github.com/tilt-dev/tilt/internal/controllers/indexer"
    23  	"github.com/tilt-dev/tilt/internal/docker"
    24  	"github.com/tilt-dev/tilt/internal/hud/server"
    25  	"github.com/tilt-dev/tilt/internal/k8s"
    26  	"github.com/tilt-dev/tilt/internal/k8s/kubeconfig"
    27  	"github.com/tilt-dev/tilt/internal/localexec"
    28  	"github.com/tilt-dev/tilt/internal/store"
    29  	"github.com/tilt-dev/tilt/internal/store/clusters"
    30  	"github.com/tilt-dev/tilt/pkg/apis"
    31  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    32  	"github.com/tilt-dev/tilt/pkg/logger"
    33  	"github.com/tilt-dev/tilt/pkg/model"
    34  )
    35  
    36  const ArchUnknown string = "unknown"
    37  
    38  const (
    39  	clientInitBackoff        = 30 * time.Second
    40  	clientHealthPollInterval = 15 * time.Second
    41  )
    42  
    43  type Reconciler struct {
    44  	globalCtx               context.Context
    45  	ctrlClient              ctrlclient.Client
    46  	store                   store.RStore
    47  	requeuer                *indexer.Requeuer
    48  	clock                   clockwork.Clock
    49  	connManager             *ConnectionManager
    50  	localDockerEnv          docker.LocalEnv
    51  	dockerClientFactory     DockerClientFactory
    52  	k8sClientFactory        KubernetesClientFactory
    53  	wsList                  *server.WebsocketList
    54  	clusterHealth           *clusterHealthMonitor
    55  	kubeconfigWriter        *kubeconfig.Writer
    56  	localKubeconfigPathOnce localexec.KubeconfigPathOnce
    57  }
    58  
    59  func (r *Reconciler) CreateBuilder(mgr ctrl.Manager) (*builder.Builder, error) {
    60  	b := ctrl.NewControllerManagedBy(mgr).
    61  		For(&v1alpha1.Cluster{}).
    62  		WatchesRawSource(r.requeuer)
    63  	return b, nil
    64  }
    65  
    66  func NewReconciler(
    67  	globalCtx context.Context,
    68  	ctrlClient ctrlclient.Client,
    69  	store store.RStore,
    70  	clock clockwork.Clock,
    71  	connManager *ConnectionManager,
    72  	localDockerEnv docker.LocalEnv,
    73  	dockerClientFactory DockerClientFactory,
    74  	k8sClientFactory KubernetesClientFactory,
    75  	wsList *server.WebsocketList,
    76  	kubeconfigWriter *kubeconfig.Writer,
    77  	localKubeconfigPathOnce localexec.KubeconfigPathOnce,
    78  ) *Reconciler {
    79  	requeuer := indexer.NewRequeuer()
    80  
    81  	return &Reconciler{
    82  		globalCtx:               globalCtx,
    83  		ctrlClient:              ctrlClient,
    84  		store:                   store,
    85  		clock:                   clock,
    86  		requeuer:                requeuer,
    87  		connManager:             connManager,
    88  		localDockerEnv:          localDockerEnv,
    89  		dockerClientFactory:     dockerClientFactory,
    90  		k8sClientFactory:        k8sClientFactory,
    91  		wsList:                  wsList,
    92  		clusterHealth:           newClusterHealthMonitor(globalCtx, clock, requeuer),
    93  		kubeconfigWriter:        kubeconfigWriter,
    94  		localKubeconfigPathOnce: localKubeconfigPathOnce,
    95  	}
    96  }
    97  
    98  func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
    99  	nn := request.NamespacedName
   100  	ctx = store.WithManifestLogHandler(ctx, r.store, model.MainTiltfileManifestName, "cluster")
   101  
   102  	var obj v1alpha1.Cluster
   103  	err := r.ctrlClient.Get(ctx, nn, &obj)
   104  	if err != nil && !apierrors.IsNotFound(err) {
   105  		return ctrl.Result{}, err
   106  	}
   107  
   108  	if apierrors.IsNotFound(err) || !obj.ObjectMeta.DeletionTimestamp.IsZero() {
   109  		r.store.Dispatch(clusters.NewClusterDeleteAction(request.Name))
   110  		r.cleanup(nn)
   111  		r.wsList.ForEach(func(ws *server.WebsocketSubscriber) {
   112  			ws.SendClusterUpdate(ctx, nn, nil)
   113  		})
   114  		return ctrl.Result{}, nil
   115  	}
   116  
   117  	// The apiserver is the source of truth, and will ensure the engine state is up to date.
   118  	r.store.Dispatch(clusters.NewClusterUpsertAction(&obj))
   119  
   120  	clusterRefreshEnabled := obj.Annotations["features.tilt.dev/cluster-refresh"] == "true"
   121  	conn, hasConnection := r.connManager.load(nn)
   122  	// If this is not the first time we've tried to connect to the cluster,
   123  	// only attempt to refresh the connection if the feature is enabled. Not
   124  	// all parts of Tilt use a dynamically-obtained client currently, which
   125  	// can result in erratic behavior if the cluster is not in a usable state
   126  	// at startup but then becomes usable, for example, as some parts of the
   127  	// system will still have k8s.explodingClient.
   128  	if hasConnection && clusterRefreshEnabled {
   129  		// If the spec changed, delete the connection and recreate it.
   130  		if !apicmp.DeepEqual(conn.spec, obj.Spec) {
   131  			r.cleanup(nn)
   132  			conn = connection{}
   133  			hasConnection = false
   134  		} else if conn.initError != "" && r.clock.Now().After(conn.createdAt.Add(clientInitBackoff)) {
   135  			hasConnection = false
   136  		}
   137  	}
   138  
   139  	var requeueAfter time.Duration
   140  	if !hasConnection {
   141  		// Create the initial connection to the cluster.
   142  		conn = connection{spec: *obj.Spec.DeepCopy(), createdAt: r.clock.Now()}
   143  		if obj.Spec.Connection != nil && obj.Spec.Connection.Kubernetes != nil {
   144  			conn.connType = connectionTypeK8s
   145  			client, err := r.createKubernetesClient(obj.DeepCopy())
   146  			if err != nil {
   147  				var initError string
   148  				if !clusterRefreshEnabled {
   149  					initError = fmt.Sprintf(
   150  						"Tilt encountered an error connecting to your Kubernetes cluster:"+
   151  							"\n\t%v"+
   152  							"\nYou will need to restart Tilt after resolving the issue.",
   153  						err)
   154  				} else {
   155  					initError = err.Error()
   156  				}
   157  				conn.initError = initError
   158  			} else {
   159  				conn.k8sClient = client
   160  			}
   161  		} else if obj.Spec.Connection != nil && obj.Spec.Connection.Docker != nil {
   162  			conn.connType = connectionTypeDocker
   163  			client, err := r.createDockerClient(obj.Spec.Connection.Docker)
   164  			if err != nil {
   165  				conn.initError = err.Error()
   166  			} else {
   167  				conn.dockerClient = client
   168  			}
   169  		}
   170  
   171  		if conn.initError != "" {
   172  			// requeue the cluster Obj so that we can attempt to re-initialize
   173  			requeueAfter = clientInitBackoff
   174  		} else {
   175  			// start monitoring the connection and requeue the Cluster obj
   176  			// for reconciliation if its runtime status changes
   177  			r.clusterHealth.Start(nn, conn)
   178  		}
   179  	}
   180  
   181  	r.populateClusterMetadata(ctx, nn, &conn)
   182  
   183  	r.connManager.store(nn, conn)
   184  
   185  	status := conn.toStatus(r.clusterHealth.GetStatus(nn))
   186  	err = r.maybeUpdateStatus(ctx, &obj, status)
   187  	if err != nil {
   188  		return ctrl.Result{}, err
   189  	}
   190  
   191  	r.wsList.ForEach(func(ws *server.WebsocketSubscriber) {
   192  		ws.SendClusterUpdate(ctx, nn, &obj)
   193  	})
   194  
   195  	return ctrl.Result{RequeueAfter: requeueAfter}, nil
   196  }
   197  
   198  // Creates a docker connection from the spec.
   199  func (r *Reconciler) createDockerClient(obj *v1alpha1.DockerClusterConnection) (docker.Client, error) {
   200  	// If no Host is specified, use the default Env from environment variables.
   201  	env := docker.Env(r.localDockerEnv)
   202  	if obj.Host != "" {
   203  		d, err := client.NewClientWithOpts(client.WithHost(obj.Host))
   204  		env.Client = d
   205  		if err != nil {
   206  			env.Error = err
   207  		}
   208  	}
   209  
   210  	client, err := r.dockerClientFactory.New(r.globalCtx, env)
   211  	if err != nil {
   212  		return nil, err
   213  	}
   214  	return client, nil
   215  }
   216  
   217  // Creates a Kubernetes client from the spec.
   218  func (r *Reconciler) createKubernetesClient(cluster *v1alpha1.Cluster) (k8s.Client, error) {
   219  	k8sKubeContextOverride := k8s.KubeContextOverride(cluster.Spec.Connection.Kubernetes.Context)
   220  	k8sNamespaceOverride := k8s.NamespaceOverride(cluster.Spec.Connection.Kubernetes.Namespace)
   221  	client, err := r.k8sClientFactory.New(r.globalCtx, k8sKubeContextOverride, k8sNamespaceOverride)
   222  	if err != nil {
   223  		return nil, err
   224  	}
   225  	return client, nil
   226  }
   227  
   228  // Reads the arch from a kubernetes cluster, or "unknown" if we can't
   229  // figure out the architecture.
   230  //
   231  // Note that it's normal that users may not have access to the kubernetes
   232  // arch if there are RBAC rules restricting read access on nodes.
   233  //
   234  // We only need to read SOME arch that the cluster supports.
   235  func (r *Reconciler) readKubernetesArch(ctx context.Context, client k8s.Client) string {
   236  	nodeMetas, err := client.ListMeta(ctx, schema.GroupVersionKind{Version: "v1", Kind: "Node"}, "")
   237  	if err != nil || len(nodeMetas) == 0 {
   238  		return ArchUnknown
   239  	}
   240  
   241  	// https://github.com/kubernetes/enhancements/blob/0e4d5df19d396511fe41ed0860b0ab9b96f46a2d/keps/sig-node/793-node-os-arch-labels/README.md
   242  	// https://kubernetes.io/docs/reference/labels-annotations-taints/#kubernetes-io-arch
   243  	arch := nodeMetas[0].GetLabels()["kubernetes.io/arch"]
   244  	if arch == "" {
   245  		arch = nodeMetas[0].GetLabels()["beta.kubernetes.io/arch"]
   246  	}
   247  
   248  	if arch == "" {
   249  		return ArchUnknown
   250  	}
   251  	return arch
   252  }
   253  
   254  // Reads the arch from a Docker cluster, or "unknown" if we can't
   255  // figure out the architecture.
   256  func (r *Reconciler) readDockerArch(ctx context.Context, client docker.Client) string {
   257  	serverVersion, err := client.ServerVersion(ctx)
   258  	if err != nil {
   259  		return ArchUnknown
   260  	}
   261  	arch := serverVersion.Arch
   262  	if arch == "" {
   263  		return ArchUnknown
   264  	}
   265  	return arch
   266  }
   267  
   268  func (r *Reconciler) maybeUpdateStatus(ctx context.Context, obj *v1alpha1.Cluster, newStatus v1alpha1.ClusterStatus) error {
   269  	if apicmp.DeepEqual(obj.Status, newStatus) {
   270  		return nil
   271  	}
   272  
   273  	update := obj.DeepCopy()
   274  	oldStatus := update.Status
   275  	update.Status = newStatus
   276  	err := r.ctrlClient.Status().Update(ctx, update)
   277  	if err != nil {
   278  		return fmt.Errorf("updating cluster %s status: %v", obj.Name, err)
   279  	}
   280  
   281  	if newStatus.Error != "" && oldStatus.Error != newStatus.Error {
   282  		logger.Get(ctx).Errorf("Cluster status error: %v", newStatus.Error)
   283  	}
   284  
   285  	r.reportConnectionEvent(ctx, update)
   286  
   287  	return nil
   288  }
   289  
   290  func (r *Reconciler) reportConnectionEvent(ctx context.Context, cluster *v1alpha1.Cluster) {
   291  	tags := make(map[string]string)
   292  
   293  	if cluster.Spec.Connection != nil {
   294  		if cluster.Spec.Connection.Kubernetes != nil {
   295  			tags["type"] = "kubernetes"
   296  		} else if cluster.Spec.Connection.Docker != nil {
   297  			tags["type"] = "docker"
   298  		}
   299  	}
   300  
   301  	if cluster.Status.Arch != "" {
   302  		tags["arch"] = cluster.Status.Arch
   303  	}
   304  
   305  	if cluster.Status.Error == "" {
   306  		tags["status"] = "connected"
   307  	} else {
   308  		tags["status"] = "error"
   309  	}
   310  
   311  	analytics.Get(ctx).Incr("api.cluster.connect", tags)
   312  }
   313  
   314  func (r *Reconciler) populateClusterMetadata(ctx context.Context, clusterNN types.NamespacedName, conn *connection) {
   315  	if conn.initError != "" {
   316  		return
   317  	}
   318  
   319  	switch conn.connType {
   320  	case connectionTypeK8s:
   321  		r.populateK8sMetadata(ctx, clusterNN, conn)
   322  	case connectionTypeDocker:
   323  		r.populateDockerMetadata(ctx, conn)
   324  	}
   325  }
   326  
   327  func (r *Reconciler) populateK8sMetadata(ctx context.Context, clusterNN types.NamespacedName, conn *connection) {
   328  	if conn.arch == "" {
   329  		conn.arch = r.readKubernetesArch(ctx, conn.k8sClient)
   330  	}
   331  
   332  	if conn.registry == nil {
   333  		reg := conn.k8sClient.LocalRegistry(ctx)
   334  		if !container.IsEmptyRegistry(reg) {
   335  			// If we've found a local registry in the cluster at run-time, use that
   336  			// instead of the default_registry (if any) declared in the Tiltfile
   337  			logger.Get(ctx).Infof("Auto-detected local registry from environment: %s", reg)
   338  
   339  			if conn.spec.DefaultRegistry != nil {
   340  				// The user has specified a default registry in their Tiltfile, but it will be ignored.
   341  				logger.Get(ctx).Infof("Default registry specified, but will be ignored in favor of auto-detected registry.")
   342  			}
   343  		} else if conn.spec.DefaultRegistry != nil {
   344  			logger.Get(ctx).Debugf("Using default registry from Tiltfile: %s", conn.spec.DefaultRegistry)
   345  		} else {
   346  			logger.Get(ctx).Debugf(
   347  				"No local registry detected and no default registry set for cluster %q",
   348  				clusterNN.Name)
   349  		}
   350  
   351  		conn.registry = reg
   352  	}
   353  
   354  	if conn.connStatus == nil {
   355  		apiConfig := conn.k8sClient.APIConfig()
   356  		k8sStatus := &v1alpha1.KubernetesClusterConnectionStatus{
   357  			Context: apiConfig.CurrentContext,
   358  			Product: string(k8s.ClusterProductFromAPIConfig(apiConfig)),
   359  		}
   360  		context, ok := apiConfig.Contexts[apiConfig.CurrentContext]
   361  		if ok {
   362  			k8sStatus.Namespace = context.Namespace
   363  			k8sStatus.Cluster = context.Cluster
   364  		}
   365  
   366  		var configPath string
   367  		var configPathError error
   368  		if clusterNN.Name == v1alpha1.ClusterNameDefault {
   369  			// If this is the default cluster, use the same kubeconfig
   370  			// we generated for local commands.
   371  			configPath = r.localKubeconfigPathOnce()
   372  		} else {
   373  
   374  			configPath, configPathError = r.kubeconfigWriter.WriteFrozenKubeConfig(ctx, clusterNN, apiConfig)
   375  		}
   376  
   377  		if configPath == "" && configPathError == nil {
   378  			// Cover the case where localKubeconfigPathOnce() swallowed the error.
   379  			configPathError = fmt.Errorf("internal error generating default kubeconfig")
   380  		}
   381  
   382  		if configPathError != nil {
   383  			conn.initError = configPathError.Error()
   384  		}
   385  
   386  		k8sStatus.ConfigPath = configPath
   387  
   388  		conn.connStatus = &v1alpha1.ClusterConnectionStatus{
   389  			Kubernetes: k8sStatus,
   390  		}
   391  	}
   392  
   393  	if conn.serverVersion == "" {
   394  		versionInfo, err := conn.k8sClient.CheckConnected(ctx)
   395  		if err == nil {
   396  			conn.serverVersion = versionInfo.String()
   397  		}
   398  	}
   399  }
   400  
   401  func (r *Reconciler) populateDockerMetadata(ctx context.Context, conn *connection) {
   402  	if conn.arch == "" {
   403  		conn.arch = r.readDockerArch(ctx, conn.dockerClient)
   404  	}
   405  
   406  	if conn.serverVersion == "" {
   407  		versionInfo, err := conn.dockerClient.ServerVersion(ctx)
   408  		if err == nil {
   409  			conn.serverVersion = versionInfo.Version
   410  		}
   411  	}
   412  }
   413  
   414  func (r *Reconciler) cleanup(clusterNN types.NamespacedName) {
   415  	r.clusterHealth.Stop(clusterNN)
   416  	r.connManager.delete(clusterNN)
   417  }
   418  
   419  func (c *connection) toStatus(statusErr string) v1alpha1.ClusterStatus {
   420  	var connectedAt *metav1.MicroTime
   421  	if c.initError == "" && !c.createdAt.IsZero() {
   422  		t := apis.NewMicroTime(c.createdAt)
   423  		connectedAt = &t
   424  	}
   425  
   426  	clusterError := c.initError
   427  	if clusterError == "" {
   428  		clusterError = statusErr
   429  	}
   430  
   431  	return v1alpha1.ClusterStatus{
   432  		Error:       clusterError,
   433  		Arch:        c.arch,
   434  		Version:     c.serverVersion,
   435  		ConnectedAt: connectedAt,
   436  		Registry:    c.registry,
   437  		Connection:  c.connStatus,
   438  	}
   439  }