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 }