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 }