github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/engine/k8srollout/podmonitor.go (about) 1 package k8srollout 2 3 import ( 4 "context" 5 "fmt" 6 "strings" 7 "time" 8 9 "github.com/google/go-cmp/cmp" 10 "github.com/jonboulle/clockwork" 11 v1 "k8s.io/api/core/v1" 12 13 "github.com/tilt-dev/tilt/internal/k8s" 14 "github.com/tilt-dev/tilt/internal/store" 15 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 16 "github.com/tilt-dev/tilt/pkg/logger" 17 "github.com/tilt-dev/tilt/pkg/model" 18 "github.com/tilt-dev/tilt/pkg/model/logstore" 19 ) 20 21 type podManifest struct { 22 pod k8s.PodID 23 manifest model.ManifestName 24 } 25 26 type PodMonitor struct { 27 pods map[podManifest]podStatus 28 trackingStarted map[podManifest]bool 29 startTime time.Time 30 } 31 32 func NewPodMonitor(clock clockwork.Clock) *PodMonitor { 33 return &PodMonitor{ 34 pods: make(map[podManifest]podStatus), 35 trackingStarted: make(map[podManifest]bool), 36 startTime: clock.Now(), 37 } 38 } 39 40 func (m *PodMonitor) diff(st store.RStore) []podStatus { 41 state := st.RLockState() 42 defer st.RUnlockState() 43 44 updates := make([]podStatus, 0) 45 active := make(map[podManifest]bool) 46 47 for _, mt := range state.Targets() { 48 ms := mt.State 49 manifest := mt.Manifest 50 pod := ms.MostRecentPod() 51 podID := k8s.PodID(pod.Name) 52 if podID.Empty() { 53 continue 54 } 55 56 key := podManifest{pod: podID, manifest: manifest.Name} 57 active[key] = true 58 59 currentStatus := newPodStatus(pod, manifest.Name) 60 if !podStatusesEqual(currentStatus, m.pods[key]) { 61 updates = append(updates, currentStatus) 62 m.pods[key] = currentStatus 63 } 64 } 65 66 for key := range m.pods { 67 if !active[key] { 68 delete(m.pods, key) 69 } 70 } 71 72 return updates 73 } 74 75 func (m *PodMonitor) OnChange(ctx context.Context, st store.RStore, _ store.ChangeSummary) error { 76 updates := m.diff(st) 77 for _, update := range updates { 78 ctx := store.WithManifestLogHandler(ctx, st, update.manifestName, spanIDForPod(update.manifestName, update.podID)) 79 m.print(ctx, update) 80 } 81 82 return nil 83 } 84 85 func (m *PodMonitor) print(ctx context.Context, update podStatus) { 86 key := podManifest{pod: update.podID, manifest: update.manifestName} 87 88 if !m.trackingStarted[key] { 89 m.trackingStarted[key] = true 90 91 if m.startTime.After(update.startTime) { 92 logger.Get(ctx).Infof("\nAttaching to existing pod (%s). Only new logs will be streamed.", update.podID) 93 return 94 } 95 logger.Get(ctx).Infof("\nTracking new pod rollout (%s):", update.podID) 96 } 97 98 m.printCondition(ctx, "Scheduled", update.scheduled, update.startTime) 99 m.printCondition(ctx, "Initialized", update.initialized, update.scheduled.LastTransitionTime.Time) 100 m.printCondition(ctx, "Ready", update.ready, update.initialized.LastTransitionTime.Time) 101 } 102 103 func (m *PodMonitor) printCondition(ctx context.Context, name string, cond v1alpha1.PodCondition, startTime time.Time) { 104 l := logger.Get(ctx).WithFields(logger.Fields{logger.FieldNameProgressID: name}) 105 106 indent := " " 107 duration := "" 108 spacerMax := 16 109 spacer := "" 110 if len(name) > spacerMax { 111 name = name[:spacerMax-1] + "…" 112 } else { 113 spacer = strings.Repeat(" ", spacerMax-len(name)) 114 } 115 116 dur := cond.LastTransitionTime.Sub(startTime) 117 if !startTime.IsZero() && !cond.LastTransitionTime.IsZero() { 118 if dur == 0 { 119 duration = "<1s" 120 } else { 121 duration = fmt.Sprint(dur.Truncate(time.Millisecond)) 122 } 123 } 124 125 if cond.Status == string(v1.ConditionTrue) { 126 l.Infof("%s┊ %s%s- %s", indent, name, spacer, duration) 127 return 128 } 129 130 // PodConditions unfortunately don't represent Jobs well: 131 // 1) If a Job runs quickly enough, we might never observe the pod in a ready state (i.e., no updates have Type="Ready" and Status="True") 132 // 2) After a Job finishes, we get a pod update with Type="Ready", Status="False", and LastTransitionTime=the job end time, 133 // meaning that we lose the time at which the pod actually transitioned to the ready state. 134 // Rather than invest in more state to track these for the Job case, let's just replace "Ready" with "Completed" 135 // and reconsider if/when a user cares. 136 if cond.Type == "Ready" && cond.Reason == "PodCompleted" { 137 name = "Completed" 138 spacer = strings.Repeat(" ", spacerMax-len(name)) 139 l.Infof("%s┊ %s%s- %s", indent, name, spacer, duration) 140 return 141 } 142 143 message := cond.Message 144 reason := cond.Reason 145 if cond.Status == "" || reason == "" || message == "" { 146 l.Infof("%s┊ %s%s- (…) Pending", indent, name, spacer) 147 return 148 } 149 150 prefix := "Not " 151 spacer = strings.Repeat(" ", spacerMax-len(name)-len(prefix)) 152 l.Infof("%s┃ %s%s%s- (%s): %s", indent, prefix, name, spacer, reason, message) 153 } 154 155 type podStatus struct { 156 podID k8s.PodID 157 manifestName model.ManifestName 158 startTime time.Time 159 scheduled v1alpha1.PodCondition 160 initialized v1alpha1.PodCondition 161 ready v1alpha1.PodCondition 162 } 163 164 func newPodStatus(pod v1alpha1.Pod, manifestName model.ManifestName) podStatus { 165 s := podStatus{podID: k8s.PodID(pod.Name), manifestName: manifestName, startTime: pod.CreatedAt.Time} 166 for _, condition := range pod.Conditions { 167 switch v1.PodConditionType(condition.Type) { 168 case v1.PodScheduled: 169 s.scheduled = condition 170 case v1.PodInitialized: 171 s.initialized = condition 172 case v1.PodReady: 173 s.ready = condition 174 } 175 } 176 return s 177 } 178 179 var podStatusAllowUnexported = cmp.AllowUnexported(podStatus{}) 180 181 func podStatusesEqual(a, b podStatus) bool { 182 return cmp.Equal(a, b, podStatusAllowUnexported) 183 } 184 185 func spanIDForPod(mn model.ManifestName, podID k8s.PodID) logstore.SpanID { 186 return logstore.SpanID(fmt.Sprintf("monitor:%s:%s", mn, podID)) 187 } 188 189 var _ store.Subscriber = &PodMonitor{}