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