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

     1  package session
     2  
     3  import (
     4  	"fmt"
     5  	"strings"
     6  	"time"
     7  
     8  	v1 "k8s.io/api/core/v1"
     9  	ctrl "sigs.k8s.io/controller-runtime"
    10  
    11  	"github.com/tilt-dev/tilt/internal/engine/buildcontrol"
    12  	"github.com/tilt-dev/tilt/internal/store/k8sconv"
    13  
    14  	"github.com/tilt-dev/tilt/internal/store"
    15  	"github.com/tilt-dev/tilt/pkg/apis"
    16  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    17  	session "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    18  	"github.com/tilt-dev/tilt/pkg/model"
    19  )
    20  
    21  func (r *Reconciler) targetsForResource(mt *store.ManifestTarget, holds buildcontrol.HoldSet, ci *v1alpha1.SessionCISpec, result *ctrl.Result) []session.Target {
    22  	var targets []session.Target
    23  
    24  	if bt := buildTarget(mt, holds); bt != nil {
    25  		targets = append(targets, *bt)
    26  	}
    27  
    28  	if rt := r.runtimeTarget(mt, holds, ci, result); rt != nil {
    29  		targets = append(targets, *rt)
    30  	}
    31  
    32  	return targets
    33  }
    34  
    35  func (r *Reconciler) k8sRuntimeTarget(mt *store.ManifestTarget, ci *v1alpha1.SessionCISpec, result *ctrl.Result) *session.Target {
    36  	krs := mt.State.K8sRuntimeState()
    37  	if mt.Manifest.PodReadinessMode() == model.PodReadinessIgnore && krs.HasEverDeployedSuccessfully && krs.PodLen() == 0 {
    38  		// HACK: engine assumes anything with an image will create a pod; PodReadinessIgnore is used in these
    39  		// 	instances to avoid getting stuck in pending forever; in reality, there's no "runtime" target being
    40  		// 	monitored by Tilt, so instead of faking it, just omit it (note: only applies AFTER first deploy so
    41  		// 	that we can determine there are no pods, so it will appear in waiting until then, which is actually
    42  		// 	desirable and matches behavior in K8sRuntimeState::RuntimeStatus())
    43  		// 	see https://github.com/tilt-dev/tilt/issues/3619
    44  		return nil
    45  	}
    46  
    47  	target := &session.Target{
    48  		Name:      fmt.Sprintf("%s:runtime", mt.Manifest.Name.String()),
    49  		Type:      k8sTargetType(mt),
    50  		Resources: []string{mt.Manifest.Name.String()},
    51  	}
    52  
    53  	if mt.State.DisableState == session.DisableStateDisabled {
    54  		target.State.Disabled = &session.TargetStateDisabled{}
    55  		return target
    56  	}
    57  
    58  	status := mt.RuntimeStatus()
    59  	pod := krs.MostRecentPod()
    60  	phase := v1.PodPhase(pod.Phase)
    61  
    62  	// A Target's StartTime / FinishTime is meant to be a total representation
    63  	// of when the YAML started deploying until when it became ready. We
    64  	// also want it to persist across pod restarts, so we can use it
    65  	// to check if the pod is within the grace period.
    66  	//
    67  	// Ideally, we'd use KubernetesApply's LastApplyStartTime, but this
    68  	// is LastSuccessfulDeployTime is good enough.
    69  	createdAt := apis.NewMicroTime(mt.State.LastSuccessfulDeployTime)
    70  	lastReadyTime := apis.NewMicroTime(krs.LastReadyOrSucceededTime)
    71  	k8sGracePeriod := time.Duration(0)
    72  	if ci != nil && ci.K8sGracePeriod != nil {
    73  		k8sGracePeriod = ci.K8sGracePeriod.Duration
    74  	}
    75  
    76  	graceStatus := v1alpha1.TargetGraceNotApplicable
    77  	if k8sGracePeriod > 0 && !createdAt.Time.IsZero() {
    78  		graceSoFar := r.clock.Since(createdAt.Time)
    79  		if k8sGracePeriod <= graceSoFar {
    80  			graceStatus = v1alpha1.TargetGraceExceeded
    81  		} else {
    82  			graceStatus = v1alpha1.TargetGraceTolerated
    83  
    84  			// Use the ctrl.Result to schedule a reconcile.
    85  			requeueAfter := k8sGracePeriod - graceSoFar
    86  			if result.RequeueAfter == 0 || result.RequeueAfter > requeueAfter {
    87  				result.RequeueAfter = requeueAfter
    88  			}
    89  		}
    90  	}
    91  
    92  	if status == v1alpha1.RuntimeStatusOK {
    93  		if v1.PodSucceeded == phase {
    94  			target.State.Terminated = &session.TargetStateTerminated{
    95  				StartTime: createdAt,
    96  			}
    97  			return target
    98  		}
    99  
   100  		target.State.Active = &session.TargetStateActive{
   101  			StartTime:     createdAt,
   102  			Ready:         true,
   103  			LastReadyTime: lastReadyTime,
   104  		}
   105  		return target
   106  	}
   107  
   108  	if status == v1alpha1.RuntimeStatusError {
   109  		if phase == v1.PodFailed {
   110  			podErr := strings.Join(pod.Errors, "; ")
   111  			if podErr == "" {
   112  				podErr = fmt.Sprintf("Pod %q failed", pod.Name)
   113  			}
   114  			target.State.Terminated = &session.TargetStateTerminated{
   115  				StartTime:   createdAt,
   116  				Error:       podErr,
   117  				GraceStatus: graceStatus,
   118  			}
   119  			return target
   120  		}
   121  
   122  		for _, ctr := range store.AllPodContainers(pod) {
   123  			if k8sconv.ContainerStatusToRuntimeState(ctr) == v1alpha1.RuntimeStatusError {
   124  				target.State.Terminated = &session.TargetStateTerminated{
   125  					StartTime: apis.NewMicroTime(pod.CreatedAt.Time),
   126  					Error: fmt.Sprintf("Pod %s in error state due to container %s: %s",
   127  						pod.Name, ctr.Name, pod.Status),
   128  					GraceStatus: graceStatus,
   129  				}
   130  				return target
   131  			}
   132  		}
   133  
   134  		target.State.Terminated = &session.TargetStateTerminated{
   135  			StartTime:   createdAt,
   136  			Error:       "unknown error",
   137  			GraceStatus: graceStatus,
   138  		}
   139  		return target
   140  	}
   141  
   142  	if status == v1alpha1.RuntimeStatusPending {
   143  		if v1.PodRunning == phase {
   144  			target.State.Active = &session.TargetStateActive{
   145  				StartTime:     createdAt,
   146  				Ready:         false,
   147  				LastReadyTime: lastReadyTime,
   148  			}
   149  			return target
   150  		}
   151  
   152  		waitReason := pod.Status
   153  		if waitReason == "" {
   154  			if pod.Name == "" {
   155  				waitReason = "waiting-for-pod"
   156  			} else {
   157  				waitReason = "unknown"
   158  			}
   159  		}
   160  		target.State.Waiting = &session.TargetStateWaiting{
   161  			WaitReason: waitReason,
   162  		}
   163  	}
   164  
   165  	return target
   166  }
   167  
   168  func (r *Reconciler) localServeTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet) *session.Target {
   169  	if mt.Manifest.LocalTarget().ServeCmd.Empty() {
   170  		// there is no serve_cmd, so don't return a runtime target at all
   171  		// (there will still be a build target from the update cmd)
   172  		return nil
   173  	}
   174  
   175  	target := &session.Target{
   176  		Name:      fmt.Sprintf("%s:serve", mt.Manifest.Name.String()),
   177  		Resources: []string{mt.Manifest.Name.String()},
   178  		Type:      session.TargetTypeServer,
   179  	}
   180  
   181  	if mt.State.DisableState == session.DisableStateDisabled {
   182  		target.State.Disabled = &session.TargetStateDisabled{}
   183  		return target
   184  	}
   185  
   186  	lrs := mt.State.LocalRuntimeState()
   187  	lastReadyTime := apis.NewMicroTime(lrs.LastReadyOrSucceededTime)
   188  	if runtimeErr := lrs.RuntimeStatusError(); runtimeErr != nil {
   189  		target.State.Terminated = &session.TargetStateTerminated{
   190  			StartTime:  apis.NewMicroTime(lrs.StartTime),
   191  			FinishTime: apis.NewMicroTime(lrs.FinishTime),
   192  			Error:      errToString(runtimeErr),
   193  		}
   194  	} else if lrs.PID != 0 {
   195  		target.State.Active = &session.TargetStateActive{
   196  			StartTime:     apis.NewMicroTime(lrs.StartTime),
   197  			Ready:         lrs.Ready,
   198  			LastReadyTime: lastReadyTime,
   199  		}
   200  	} else if mt.Manifest.TriggerMode.AutoInitial() || mt.State.StartedFirstBuild() {
   201  		// default to waiting unless this resource has auto_init=False and has never
   202  		// had a build triggered for other reasons (e.g. trigger_mode=TRIGGER_MODE_AUTO and
   203  		// a relevant file change or being manually invoked via UI)
   204  		// the latter case ensures there's no race condition between a build being
   205  		// triggered and the local process actually being launched
   206  		//
   207  		// otherwise, Terminated/Active/Waiting will all be nil, which indicates that
   208  		// the target is currently inactive
   209  		target.State.Waiting = waitingFromHolds(mt.Manifest.Name, holds)
   210  	}
   211  
   212  	return target
   213  }
   214  
   215  // genericRuntimeTarget creates a target from the RuntimeState interface without any domain-specific considerations.
   216  //
   217  // This is both used for target types that don't require specialized logic (Docker Compose) as well as a fallback for
   218  // any new types that don't have deeper support here.
   219  func (r *Reconciler) genericRuntimeTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet) *session.Target {
   220  	target := &session.Target{
   221  		Name:      fmt.Sprintf("%s:runtime", mt.Manifest.Name.String()),
   222  		Resources: []string{mt.Manifest.Name.String()},
   223  		Type:      session.TargetTypeServer,
   224  	}
   225  
   226  	if mt.State.DisableState == session.DisableStateDisabled {
   227  		target.State.Disabled = &session.TargetStateDisabled{}
   228  		return target
   229  	}
   230  
   231  	runtimeStatus := mt.RuntimeStatus()
   232  	switch runtimeStatus {
   233  	case v1alpha1.RuntimeStatusPending:
   234  		target.State.Waiting = waitingFromHolds(mt.Manifest.Name, holds)
   235  	case v1alpha1.RuntimeStatusOK:
   236  		target.State.Active = &session.TargetStateActive{
   237  			StartTime: apis.NewMicroTime(mt.State.LastSuccessfulDeployTime),
   238  			// generic resources have no readiness concept so they're just ready by default
   239  			// (this also applies to Docker Compose, since we don't support its health checks)
   240  			Ready:         true,
   241  			LastReadyTime: apis.NewMicroTime(mt.State.LastSuccessfulDeployTime),
   242  		}
   243  	case v1alpha1.RuntimeStatusError:
   244  		errMsg := errToString(mt.State.RuntimeState.RuntimeStatusError())
   245  		if errMsg == "" {
   246  			errMsg = "Server target %q failed"
   247  		}
   248  		target.State.Terminated = &session.TargetStateTerminated{
   249  			Error: errMsg,
   250  		}
   251  	}
   252  
   253  	return target
   254  }
   255  
   256  func (r *Reconciler) runtimeTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet, ci *v1alpha1.SessionCISpec, result *ctrl.Result) *session.Target {
   257  	if mt.Manifest.IsK8s() {
   258  		return r.k8sRuntimeTarget(mt, ci, result)
   259  	} else if mt.Manifest.IsLocal() {
   260  		return r.localServeTarget(mt, holds)
   261  	} else {
   262  		return r.genericRuntimeTarget(mt, holds)
   263  	}
   264  }
   265  
   266  // buildTarget creates a "build" (or update) target for the resource.
   267  //
   268  // Currently, the engine aggregates many different targets into a single build record, and that's reflected here.
   269  // Ideally, as the internals change, more granularity will provided and this might actually return a slice of targets
   270  // rather than a single target. For example, a K8s resource might have an image build step and then a deployment (i.e.
   271  // kubectl apply) step - currently, both of these will be aggregated together, which can make it harder to diagnose
   272  // where something is stuck or slow.
   273  func buildTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet) *session.Target {
   274  	if mt.Manifest.IsLocal() && mt.Manifest.LocalTarget().UpdateCmdSpec == nil {
   275  		return nil
   276  	}
   277  
   278  	res := &session.Target{
   279  		Name:      fmt.Sprintf("%s:update", mt.Manifest.Name.String()),
   280  		Resources: []string{mt.Manifest.Name.String()},
   281  		Type:      session.TargetTypeJob,
   282  	}
   283  
   284  	if mt.State.DisableState == session.DisableStateDisabled {
   285  		res.State.Disabled = &session.TargetStateDisabled{}
   286  		return res
   287  	}
   288  
   289  	isPending := mt.NextBuildReason() != model.BuildReasonNone
   290  	currentBuild := mt.State.EarliestCurrentBuild()
   291  	if isPending {
   292  		res.State.Waiting = waitingFromHolds(mt.Manifest.Name, holds)
   293  	} else if !currentBuild.Empty() {
   294  		res.State.Active = &session.TargetStateActive{
   295  			StartTime: apis.NewMicroTime(currentBuild.StartTime),
   296  		}
   297  	} else if len(mt.State.BuildHistory) != 0 {
   298  		lastBuild := mt.State.LastBuild()
   299  		res.State.Terminated = &session.TargetStateTerminated{
   300  			StartTime:  apis.NewMicroTime(lastBuild.StartTime),
   301  			FinishTime: apis.NewMicroTime(lastBuild.FinishTime),
   302  			Error:      errToString(lastBuild.Error),
   303  		}
   304  	}
   305  
   306  	return res
   307  }
   308  
   309  func k8sTargetType(mt *store.ManifestTarget) session.TargetType {
   310  	if !mt.Manifest.IsK8s() {
   311  		return ""
   312  	}
   313  
   314  	krs := mt.State.K8sRuntimeState()
   315  	if krs.PodReadinessMode == model.PodReadinessSucceeded {
   316  		return session.TargetTypeJob
   317  	}
   318  
   319  	return session.TargetTypeServer
   320  }
   321  
   322  func waitingFromHolds(mn model.ManifestName, holds buildcontrol.HoldSet) *session.TargetStateWaiting {
   323  	// in the API, the reason is not _why_ the target "exists", but rather an explanation for why it's not yet
   324  	// active and is in a pending state (e.g. waitingFromHolds for dependencies)
   325  	waitReason := "unknown"
   326  	if hold, ok := holds[mn]; ok && hold.Reason != store.HoldReasonNone {
   327  		waitReason = string(hold.Reason)
   328  	}
   329  	return &session.TargetStateWaiting{
   330  		WaitReason: waitReason,
   331  	}
   332  }
   333  
   334  // tiltfileTarget creates a session.Target object from a Tiltfile ManifestState
   335  //
   336  // This is slightly different from generic resource handling because there is no
   337  // ManifestTarget in the engine for the Tiltfile (just ManifestState) and config
   338  // file changes are stored stop level on state, but conceptually it does similar
   339  // things.
   340  func tiltfileTarget(name model.ManifestName, ms *store.ManifestState) session.Target {
   341  	target := session.Target{
   342  		Name:      "tiltfile:update",
   343  		Resources: []string{name.String()},
   344  		Type:      session.TargetTypeJob,
   345  	}
   346  
   347  	// Tiltfile is special in engine state and doesn't have a target, just state, so
   348  	// this logic is largely duplicated from the generic resource build logic
   349  	if ms.IsBuilding() {
   350  		target.State.Active = &session.TargetStateActive{
   351  			StartTime: apis.NewMicroTime(ms.EarliestCurrentBuild().StartTime),
   352  		}
   353  	} else if hasPendingChanges, _ := ms.HasPendingChanges(); hasPendingChanges {
   354  		target.State.Waiting = &session.TargetStateWaiting{
   355  			WaitReason: "config-changed",
   356  		}
   357  	} else if len(ms.BuildHistory) != 0 {
   358  		lastBuild := ms.LastBuild()
   359  		target.State.Terminated = &session.TargetStateTerminated{
   360  			StartTime:  apis.NewMicroTime(lastBuild.StartTime),
   361  			FinishTime: apis.NewMicroTime(lastBuild.FinishTime),
   362  			Error:      errToString(lastBuild.Error),
   363  		}
   364  	} else {
   365  		// given the current engine behavior, this doesn't actually occur because
   366  		// the first build happens as part of initialization
   367  		target.State.Waiting = &session.TargetStateWaiting{
   368  			WaitReason: "initial-build",
   369  		}
   370  	}
   371  
   372  	return target
   373  }