github.com/tilt-dev/tilt@v0.36.0/internal/hud/webview/convert.go (about)

     1  package webview
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"sort"
     7  	"strings"
     8  
     9  	"github.com/pkg/errors"
    10  	"google.golang.org/protobuf/types/known/timestamppb"
    11  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    12  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    13  	"k8s.io/apimachinery/pkg/runtime/schema"
    14  	"k8s.io/apimachinery/pkg/types"
    15  	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
    16  
    17  	"github.com/tilt-dev/tilt/internal/controllers/apis/uiresource"
    18  	"github.com/tilt-dev/tilt/internal/engine/buildcontrol"
    19  	"github.com/tilt-dev/tilt/internal/k8s"
    20  	"github.com/tilt-dev/tilt/internal/store"
    21  	"github.com/tilt-dev/tilt/internal/store/k8sconv"
    22  	"github.com/tilt-dev/tilt/pkg/apis"
    23  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    24  	"github.com/tilt-dev/tilt/pkg/logger"
    25  	"github.com/tilt-dev/tilt/pkg/model"
    26  	"github.com/tilt-dev/tilt/pkg/model/logstore"
    27  	proto_webview "github.com/tilt-dev/tilt/pkg/webview"
    28  )
    29  
    30  // We call the main session the Tiltfile session, for compatibility
    31  // with the other Session API.
    32  const UISessionName = "Tiltfile"
    33  
    34  // Create the complete snapshot of the webview.
    35  func CompleteView(ctx context.Context, client ctrlclient.Client, st store.RStore) (*proto_webview.View, error) {
    36  	ret := &proto_webview.View{}
    37  	session := &v1alpha1.UISession{}
    38  	err := client.Get(ctx, types.NamespacedName{Name: UISessionName}, session)
    39  	if err != nil && !apierrors.IsNotFound(err) {
    40  		return nil, err
    41  	}
    42  
    43  	if err == nil {
    44  		ret.UiSession = session
    45  	}
    46  
    47  	resourceList := &v1alpha1.UIResourceList{}
    48  	err = client.List(ctx, resourceList)
    49  	if err != nil {
    50  		return nil, err
    51  	}
    52  
    53  	for _, item := range resourceList.Items {
    54  		ret.UiResources = append(ret.UiResources, &item)
    55  	}
    56  
    57  	buttonList := &v1alpha1.UIButtonList{}
    58  	err = client.List(ctx, buttonList)
    59  	if err != nil {
    60  		return nil, err
    61  	}
    62  
    63  	for _, item := range buttonList.Items {
    64  		ret.UiButtons = append(ret.UiButtons, &item)
    65  	}
    66  
    67  	clusterList := &v1alpha1.ClusterList{}
    68  	err = client.List(ctx, clusterList)
    69  	if err != nil {
    70  		return nil, err
    71  	}
    72  
    73  	for _, item := range clusterList.Items {
    74  		ret.Clusters = append(ret.Clusters, &item)
    75  	}
    76  
    77  	s := st.RLockState()
    78  	defer st.RUnlockState()
    79  	logList, err := s.LogStore.ToLogList(0)
    80  	if err != nil {
    81  		return nil, err
    82  	}
    83  
    84  	ret.LogList = logList
    85  
    86  	// We grandfather in TiltStartTime from the old protocol,
    87  	// because it tells the UI to reload.
    88  	start := timestamppb.New(s.TiltStartTime)
    89  	ret.TiltStartTime = start
    90  	ret.IsComplete = true
    91  
    92  	sortUIResources(ret.UiResources, s.ManifestDefinitionOrder)
    93  
    94  	return ret, nil
    95  }
    96  
    97  // Create a view that only contains logs since the given checkpoint.
    98  func LogUpdate(st store.RStore, checkpoint logstore.Checkpoint) (*proto_webview.View, error) {
    99  	ret := &proto_webview.View{}
   100  
   101  	s := st.RLockState()
   102  	defer st.RUnlockState()
   103  	logList, err := s.LogStore.ToLogList(checkpoint)
   104  	if err != nil {
   105  		return nil, err
   106  	}
   107  
   108  	ret.LogList = logList
   109  
   110  	// We grandfather in TiltStartTime from the old protocol,
   111  	// because it tells the UI to reload.
   112  	start := timestamppb.New(s.TiltStartTime)
   113  	ret.TiltStartTime = start
   114  
   115  	return ret, nil
   116  }
   117  
   118  func sortUIResources(resources []*v1alpha1.UIResource, order []model.ManifestName) {
   119  	resourceOrder := make(map[string]int, len(order))
   120  	for i, name := range order {
   121  		resourceOrder[name.String()] = i
   122  	}
   123  	resourceOrder[store.MainTiltfileManifestName.String()] = -1
   124  	sort.Slice(resources, func(i, j int) bool {
   125  		objI := resources[i]
   126  		objJ := resources[j]
   127  		orderI, hasI := resourceOrder[objI.Name]
   128  		orderJ, hasJ := resourceOrder[objJ.Name]
   129  		if !hasI {
   130  			orderI = 1000
   131  		}
   132  		if !hasJ {
   133  			orderJ = 1000
   134  		}
   135  		if orderI != orderJ {
   136  			return orderI < orderJ
   137  		}
   138  		return objI.Name < objJ.Name
   139  	})
   140  }
   141  
   142  // Converts EngineState into the public data model representation, a UISession.
   143  func ToUISession(s store.EngineState) *v1alpha1.UISession {
   144  	ret := &v1alpha1.UISession{
   145  		ObjectMeta: metav1.ObjectMeta{
   146  			Name: UISessionName,
   147  		},
   148  		Status: v1alpha1.UISessionStatus{},
   149  	}
   150  
   151  	status := &(ret.Status)
   152  	status.NeedsAnalyticsNudge = NeedsNudge(s)
   153  	status.RunningTiltBuild = v1alpha1.TiltBuild{
   154  		Version:   s.TiltBuildInfo.Version,
   155  		CommitSHA: s.TiltBuildInfo.CommitSHA,
   156  		Dev:       s.TiltBuildInfo.Dev,
   157  		Date:      s.TiltBuildInfo.Date,
   158  	}
   159  	status.SuggestedTiltVersion = s.SuggestedTiltVersion
   160  	status.FeatureFlags = []v1alpha1.UIFeatureFlag{}
   161  	for k, v := range s.Features {
   162  		status.FeatureFlags = append(status.FeatureFlags, v1alpha1.UIFeatureFlag{
   163  			Name:  k,
   164  			Value: v,
   165  		})
   166  	}
   167  	sort.Slice(status.FeatureFlags, func(i, j int) bool {
   168  		return status.FeatureFlags[i].Name < status.FeatureFlags[j].Name
   169  	})
   170  	if s.FatalError != nil {
   171  		status.FatalError = s.FatalError.Error()
   172  	}
   173  
   174  	status.VersionSettings = v1alpha1.VersionSettings{
   175  		CheckUpdates: s.VersionSettings.CheckUpdates,
   176  	}
   177  
   178  	status.TiltStartTime = metav1.NewTime(s.TiltStartTime)
   179  
   180  	status.TiltfileKey = s.MainTiltfilePath()
   181  
   182  	return ret
   183  }
   184  
   185  // Converts an EngineState into a list of UIResources.
   186  // The order of the list is non-deterministic.
   187  func ToUIResourceList(state store.EngineState, disableSources map[string][]v1alpha1.DisableSource) ([]*v1alpha1.UIResource, error) {
   188  	ret := make([]*v1alpha1.UIResource, 0, len(state.ManifestTargets)+1)
   189  
   190  	// All tiltfiles appear earlier than other resources in the same group.
   191  	for _, name := range state.TiltfileDefinitionOrder {
   192  		ms, ok := state.TiltfileStates[name]
   193  		if !ok {
   194  			continue
   195  		}
   196  
   197  		if m, ok := state.Manifest(name); ok {
   198  			// TODO(milas): this is a hacky check to prevent creating Tiltfile
   199  			// 	resources with the same name as k8s/dc/local resources, which
   200  			// 	are held independently in the engine state; due to the way that
   201  			// 	extension/Tiltfile loading happens in multiple phases split
   202  			// 	between apiserver reconciler & engine reducer, there's currently
   203  			// 	not a practical way to enforce uniqueness upfront, so we return
   204  			// 	an error here, which will be fatal - the UX here is not great,
   205  			// 	but this is hopefully enough of an edge case that users don't
   206  			// 	hit it super frequently, and it prevents difficult to debug,
   207  			// 	erratic behavior in the Tilt UI
   208  			tfType := "Tiltfile"
   209  			if isExtensionTiltfile(state.Tiltfiles[name.String()]) {
   210  				tfType = "Extension"
   211  			}
   212  
   213  			return nil, fmt.Errorf(
   214  				"%s %q has the same name as a %s resource",
   215  				tfType, name, manifestType(m))
   216  		}
   217  
   218  		r := TiltfileResource(name, ms, state.LogStore)
   219  		r.Status.Order = int32(len(ret) + 1)
   220  		ret = append(ret, r)
   221  	}
   222  
   223  	_, holds := buildcontrol.NextTargetToBuild(state)
   224  
   225  	for _, mt := range state.Targets() {
   226  		mn := mt.Manifest.Name
   227  		r, err := toUIResource(mt, state, disableSources[mn.String()], holds[mn])
   228  		if err != nil {
   229  			return nil, err
   230  		}
   231  
   232  		r.Status.Order = int32(len(ret) + 1)
   233  		ret = append(ret, r)
   234  	}
   235  
   236  	return ret, nil
   237  }
   238  
   239  func disableResourceStatus(disableSources []v1alpha1.DisableSource, s store.EngineState) (v1alpha1.DisableResourceStatus, error) {
   240  	getCM := func(name string) (v1alpha1.ConfigMap, error) {
   241  		cm, ok := s.ConfigMaps[name]
   242  		if !ok {
   243  			gr := (&v1alpha1.ConfigMap{}).GetGroupVersionResource().GroupResource()
   244  			return v1alpha1.ConfigMap{}, apierrors.NewNotFound(gr, name)
   245  		}
   246  		return *cm, nil
   247  	}
   248  	return uiresource.DisableResourceStatus(getCM, disableSources)
   249  }
   250  
   251  // Converts a ManifestTarget into the public data model representation,
   252  // a UIResource.
   253  func toUIResource(mt *store.ManifestTarget, s store.EngineState, disableSources []v1alpha1.DisableSource, hold store.Hold) (*v1alpha1.UIResource, error) {
   254  	mn := mt.Manifest.Name
   255  	ms := mt.State
   256  	endpoints := store.ManifestTargetEndpoints(mt)
   257  
   258  	bh := ToBuildsTerminated(ms.BuildHistory, s.LogStore)
   259  	lastDeploy := metav1.NewMicroTime(ms.LastSuccessfulDeployTime)
   260  	currentBuild := ms.EarliestCurrentBuild()
   261  	cb := ToBuildRunning(currentBuild)
   262  
   263  	specs, err := ToAPITargetSpecs(mt.Manifest.TargetSpecs())
   264  	if err != nil {
   265  		return nil, err
   266  	}
   267  
   268  	// NOTE(nick): Right now, the UX is designed to show the output exactly one
   269  	// pod. A better UI might summarize the pods in other ways (e.g., show the
   270  	// "most interesting" pod that's crash looping, or show logs from all pods
   271  	// at once).
   272  	hasPendingChanges, pendingBuildSince := ms.HasPendingChanges()
   273  
   274  	drs, err := disableResourceStatus(disableSources, s)
   275  	if err != nil {
   276  		return nil, errors.Wrap(err, "error determining disable resource status")
   277  	}
   278  
   279  	r := &v1alpha1.UIResource{
   280  		ObjectMeta: metav1.ObjectMeta{
   281  			Name:   mn.String(),
   282  			Labels: mt.Manifest.Labels,
   283  		},
   284  		Status: v1alpha1.UIResourceStatus{
   285  			LastDeployTime:    lastDeploy,
   286  			BuildHistory:      bh,
   287  			PendingBuildSince: metav1.NewMicroTime(pendingBuildSince),
   288  			CurrentBuild:      cb,
   289  			EndpointLinks:     ToAPILinks(endpoints),
   290  			Specs:             specs,
   291  			TriggerMode:       int32(mt.Manifest.TriggerMode),
   292  			HasPendingChanges: hasPendingChanges,
   293  			Queued:            s.ManifestInTriggerQueue(mn),
   294  			DisableStatus:     drs,
   295  			Waiting:           holdToWaiting(hold),
   296  		},
   297  	}
   298  
   299  	populateResourceInfoView(mt, r)
   300  
   301  	r.Status.Conditions = []v1alpha1.UIResourceCondition{
   302  		UIResourceUpToDateCondition(r.Status),
   303  		UIResourceReadyCondition(r.Status),
   304  	}
   305  	return r, nil
   306  }
   307  
   308  // The "Ready" condition is a cross-resource status report that's synthesized
   309  // from the more type-specific fields of UIResource.
   310  func UIResourceReadyCondition(r v1alpha1.UIResourceStatus) v1alpha1.UIResourceCondition {
   311  	c := v1alpha1.UIResourceCondition{
   312  		Type:   v1alpha1.UIResourceReady,
   313  		Status: metav1.ConditionUnknown,
   314  
   315  		// LastTransitionTime will be computed by diffing against the current
   316  		// Condition. This doesn't really fit into the usual reconciler pattern,
   317  		// but is considered a worthwhile trade-off for the semantics we want, see discussion here:
   318  		// https://maelvls.dev/kubernetes-conditions/
   319  		LastTransitionTime: apis.NowMicro(),
   320  	}
   321  
   322  	if r.RuntimeStatus == v1alpha1.RuntimeStatusOK {
   323  		c.Status = metav1.ConditionTrue
   324  		return c
   325  	}
   326  
   327  	if r.RuntimeStatus == v1alpha1.RuntimeStatusNotApplicable && r.UpdateStatus == v1alpha1.UpdateStatusOK {
   328  		c.Status = metav1.ConditionTrue
   329  		return c
   330  	}
   331  
   332  	c.Status = metav1.ConditionFalse
   333  	if r.DisableStatus.State == v1alpha1.DisableStateDisabled {
   334  		c.Reason = "Disabled"
   335  	} else if r.RuntimeStatus == v1alpha1.RuntimeStatusError {
   336  		c.Reason = "RuntimeError"
   337  	} else if r.UpdateStatus == v1alpha1.UpdateStatusError {
   338  		c.Reason = "UpdateError"
   339  	} else if r.UpdateStatus == v1alpha1.UpdateStatusOK && r.RuntimeStatus == v1alpha1.RuntimeStatusPending {
   340  		c.Reason = "RuntimePending"
   341  	} else if r.UpdateStatus == v1alpha1.UpdateStatusPending {
   342  		c.Reason = "UpdatePending"
   343  	} else {
   344  		c.Reason = "Unknown"
   345  	}
   346  	return c
   347  }
   348  
   349  // The "UpToDate" condition is a cross-resource status report that's synthesized
   350  // from the more type-specific fields of UIResource.
   351  func UIResourceUpToDateCondition(r v1alpha1.UIResourceStatus) v1alpha1.UIResourceCondition {
   352  	c := v1alpha1.UIResourceCondition{
   353  		Type:               v1alpha1.UIResourceUpToDate,
   354  		Status:             metav1.ConditionUnknown,
   355  		LastTransitionTime: apis.NowMicro(),
   356  	}
   357  
   358  	if r.UpdateStatus == v1alpha1.UpdateStatusOK || r.UpdateStatus == v1alpha1.UpdateStatusNotApplicable {
   359  		c.Status = metav1.ConditionTrue
   360  		return c
   361  	}
   362  
   363  	c.Status = metav1.ConditionFalse
   364  	if r.DisableStatus.State == v1alpha1.DisableStateDisabled {
   365  		c.Reason = "Disabled"
   366  	} else if r.UpdateStatus == v1alpha1.UpdateStatusError {
   367  		c.Reason = "UpdateError"
   368  	} else if r.UpdateStatus == v1alpha1.UpdateStatusPending {
   369  		c.Reason = "UpdatePending"
   370  	} else {
   371  		c.Reason = "Unknown"
   372  	}
   373  	return c
   374  }
   375  
   376  // TODO(nick): We should build this from the Tiltfile in the apiserver,
   377  // not the Tiltfile state in EngineState.
   378  func TiltfileResource(name model.ManifestName, ms *store.ManifestState, logStore *logstore.LogStore) *v1alpha1.UIResource {
   379  	ltfb := ms.LastBuild()
   380  	ctfb := ms.EarliestCurrentBuild()
   381  
   382  	pctfb := ToBuildRunning(ctfb)
   383  	history := []v1alpha1.UIBuildTerminated{}
   384  	if !ltfb.Empty() {
   385  		history = append(history, ToBuildTerminated(ltfb, logStore))
   386  	}
   387  	tr := &v1alpha1.UIResource{
   388  		ObjectMeta: metav1.ObjectMeta{
   389  			Name: string(name),
   390  		},
   391  		Status: v1alpha1.UIResourceStatus{
   392  			CurrentBuild:  pctfb,
   393  			BuildHistory:  history,
   394  			RuntimeStatus: v1alpha1.RuntimeStatusNotApplicable,
   395  			UpdateStatus:  ms.UpdateStatus(model.TriggerModeAuto),
   396  		},
   397  	}
   398  	start := metav1.NewMicroTime(ctfb.StartTime)
   399  	finish := metav1.NewMicroTime(ltfb.FinishTime)
   400  	if !ctfb.Empty() {
   401  		tr.Status.PendingBuildSince = start
   402  	} else {
   403  		tr.Status.LastDeployTime = finish
   404  	}
   405  
   406  	tr.Status.Conditions = []v1alpha1.UIResourceCondition{
   407  		UIResourceUpToDateCondition(tr.Status),
   408  		UIResourceReadyCondition(tr.Status),
   409  	}
   410  
   411  	return tr
   412  }
   413  
   414  func populateResourceInfoView(mt *store.ManifestTarget, r *v1alpha1.UIResource) {
   415  	r.Status.UpdateStatus = mt.UpdateStatus()
   416  	r.Status.RuntimeStatus = mt.RuntimeStatus()
   417  
   418  	if r.Status.DisableStatus.State == v1alpha1.DisableStateDisabled {
   419  		r.Status.UpdateStatus = v1alpha1.UpdateStatusNone
   420  		r.Status.RuntimeStatus = v1alpha1.RuntimeStatusNone
   421  	}
   422  
   423  	if mt.Manifest.IsLocal() {
   424  		lState := mt.State.LocalRuntimeState()
   425  		r.Status.LocalResourceInfo = &v1alpha1.UIResourceLocal{PID: int64(lState.PID)}
   426  	}
   427  	if mt.Manifest.IsDC() {
   428  		r.Status.ComposeResourceInfo = &v1alpha1.UIResourceCompose{
   429  			HealthStatus: mt.State.DCRuntimeState().ContainerState.HealthStatus,
   430  		}
   431  	}
   432  	if mt.Manifest.IsK8s() {
   433  		kState := mt.State.K8sRuntimeState()
   434  		pod := kState.MostRecentPod()
   435  		podID := k8s.PodID(pod.Name)
   436  		rK8s := &v1alpha1.UIResourceKubernetes{
   437  			PodName:            pod.Name,
   438  			PodCreationTime:    pod.CreatedAt,
   439  			PodUpdateStartTime: apis.NewTime(kState.UpdateStartTime[k8s.PodID(pod.Name)]),
   440  			PodStatus:          pod.Status,
   441  			PodStatusMessage:   strings.Join(pod.Errors, "\n"),
   442  			AllContainersReady: store.AllPodContainersReady(pod),
   443  			PodRestarts:        kState.VisiblePodContainerRestarts(podID),
   444  			DisplayNames:       kState.EntityDisplayNames(),
   445  		}
   446  		if podID != "" {
   447  			rK8s.SpanID = string(k8sconv.SpanIDForPod(mt.Manifest.Name, podID))
   448  		}
   449  		r.Status.K8sResourceInfo = rK8s
   450  	}
   451  }
   452  
   453  func LogSegmentToEvent(seg *proto_webview.LogSegment, spans map[string]*proto_webview.LogSpan) store.LogAction {
   454  	span, ok := spans[seg.SpanId]
   455  	if !ok {
   456  		// nonexistent span, ignore
   457  		return store.LogAction{}
   458  	}
   459  
   460  	// TODO(maia): actually get level (just spoofing for now)
   461  	spoofedLevel := logger.InfoLvl
   462  	return store.NewLogAction(model.ManifestName(span.ManifestName), logstore.SpanID(seg.SpanId), spoofedLevel, seg.Fields, []byte(seg.Text))
   463  }
   464  
   465  func holdToWaiting(hold store.Hold) *v1alpha1.UIResourceStateWaiting {
   466  	if hold.Reason == store.HoldReasonNone ||
   467  		// "Reconciling" just means the live update is handling the update (rather
   468  		// than the BuildController) and isn't indicative of a real waiting status.
   469  		hold.Reason == store.HoldReasonReconciling {
   470  		return nil
   471  	}
   472  	waiting := &v1alpha1.UIResourceStateWaiting{
   473  		Reason: string(hold.Reason),
   474  	}
   475  
   476  	if hold.OnRefs != nil {
   477  		waiting.On = hold.OnRefs
   478  		return waiting
   479  	}
   480  
   481  	for _, targetID := range hold.HoldOn {
   482  		var gvk schema.GroupVersionKind
   483  		switch targetID.Type {
   484  		case model.TargetTypeManifest:
   485  			gvk = v1alpha1.SchemeGroupVersion.WithKind("UIResource")
   486  		case model.TargetTypeImage:
   487  			gvk = v1alpha1.SchemeGroupVersion.WithKind("ImageMap")
   488  		default:
   489  			continue
   490  		}
   491  
   492  		waiting.On = append(
   493  			waiting.On, v1alpha1.UIResourceStateWaitingOnRef{
   494  				Group:      gvk.Group,
   495  				APIVersion: gvk.Version,
   496  				Kind:       gvk.Kind,
   497  				Name:       targetID.Name.String(),
   498  			},
   499  		)
   500  	}
   501  	return waiting
   502  }
   503  
   504  func manifestType(m model.Manifest) string {
   505  	if m.IsK8s() {
   506  		return "Kubernetes"
   507  	}
   508  	if m.IsDC() {
   509  		return "Docker Compose"
   510  	}
   511  	if m.IsLocal() {
   512  		return "local"
   513  	}
   514  	return "unknown"
   515  }
   516  
   517  func isExtensionTiltfile(tf *v1alpha1.Tiltfile) bool {
   518  	if tf == nil {
   519  		return false
   520  	}
   521  	ownerRefs := tf.GetOwnerReferences()
   522  	for i := range ownerRefs {
   523  		if ownerRefs[i].Kind == "Extension" {
   524  			return true
   525  		}
   526  	}
   527  	return false
   528  }