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 }