github.com/tilt-dev/tilt@v0.36.0/internal/controllers/core/session/conv.go (about) 1 package session 2 3 import ( 4 "fmt" 5 "strings" 6 "time" 7 8 v1 "k8s.io/api/core/v1" 9 ctrl "sigs.k8s.io/controller-runtime" 10 11 "github.com/tilt-dev/tilt/internal/engine/buildcontrol" 12 "github.com/tilt-dev/tilt/internal/store/k8sconv" 13 14 "github.com/tilt-dev/tilt/internal/store" 15 "github.com/tilt-dev/tilt/pkg/apis" 16 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 17 session "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 18 "github.com/tilt-dev/tilt/pkg/model" 19 ) 20 21 func (r *Reconciler) targetsForResource(mt *store.ManifestTarget, holds buildcontrol.HoldSet, ci *v1alpha1.SessionCISpec, result *ctrl.Result) []session.Target { 22 var targets []session.Target 23 24 if bt := buildTarget(mt, holds); bt != nil { 25 targets = append(targets, *bt) 26 } 27 28 if rt := r.runtimeTarget(mt, holds, ci, result); rt != nil { 29 targets = append(targets, *rt) 30 } 31 32 return targets 33 } 34 35 func (r *Reconciler) k8sRuntimeTarget(mt *store.ManifestTarget, ci *v1alpha1.SessionCISpec, result *ctrl.Result) *session.Target { 36 krs := mt.State.K8sRuntimeState() 37 if mt.Manifest.PodReadinessMode() == model.PodReadinessIgnore && krs.HasEverDeployedSuccessfully && krs.PodLen() == 0 { 38 // HACK: engine assumes anything with an image will create a pod; PodReadinessIgnore is used in these 39 // instances to avoid getting stuck in pending forever; in reality, there's no "runtime" target being 40 // monitored by Tilt, so instead of faking it, just omit it (note: only applies AFTER first deploy so 41 // that we can determine there are no pods, so it will appear in waiting until then, which is actually 42 // desirable and matches behavior in K8sRuntimeState::RuntimeStatus()) 43 // see https://github.com/tilt-dev/tilt/issues/3619 44 return nil 45 } 46 47 target := &session.Target{ 48 Name: fmt.Sprintf("%s:runtime", mt.Manifest.Name.String()), 49 Type: k8sTargetType(mt), 50 Resources: []string{mt.Manifest.Name.String()}, 51 } 52 53 if mt.State.DisableState == session.DisableStateDisabled { 54 target.State.Disabled = &session.TargetStateDisabled{} 55 return target 56 } 57 58 status := mt.RuntimeStatus() 59 pod := krs.MostRecentPod() 60 phase := v1.PodPhase(pod.Phase) 61 62 // A Target's StartTime / FinishTime is meant to be a total representation 63 // of when the YAML started deploying until when it became ready. We 64 // also want it to persist across pod restarts, so we can use it 65 // to check if the pod is within the grace period. 66 // 67 // Ideally, we'd use KubernetesApply's LastApplyStartTime, but this 68 // is LastSuccessfulDeployTime is good enough. 69 createdAt := apis.NewMicroTime(mt.State.LastSuccessfulDeployTime) 70 lastReadyTime := apis.NewMicroTime(krs.LastReadyOrSucceededTime) 71 k8sGracePeriod := time.Duration(0) 72 if ci != nil && ci.K8sGracePeriod != nil { 73 k8sGracePeriod = ci.K8sGracePeriod.Duration 74 } 75 76 graceStatus := v1alpha1.TargetGraceNotApplicable 77 if k8sGracePeriod > 0 && !createdAt.Time.IsZero() { 78 graceSoFar := r.clock.Since(createdAt.Time) 79 if k8sGracePeriod <= graceSoFar { 80 graceStatus = v1alpha1.TargetGraceExceeded 81 } else { 82 graceStatus = v1alpha1.TargetGraceTolerated 83 84 // Use the ctrl.Result to schedule a reconcile. 85 requeueAfter := k8sGracePeriod - graceSoFar 86 if result.RequeueAfter == 0 || result.RequeueAfter > requeueAfter { 87 result.RequeueAfter = requeueAfter 88 } 89 } 90 } 91 92 if status == v1alpha1.RuntimeStatusOK { 93 if v1.PodSucceeded == phase { 94 target.State.Terminated = &session.TargetStateTerminated{ 95 StartTime: createdAt, 96 } 97 return target 98 } 99 100 target.State.Active = &session.TargetStateActive{ 101 StartTime: createdAt, 102 Ready: true, 103 LastReadyTime: lastReadyTime, 104 } 105 return target 106 } 107 108 if status == v1alpha1.RuntimeStatusError { 109 if phase == v1.PodFailed { 110 podErr := strings.Join(pod.Errors, "; ") 111 if podErr == "" { 112 podErr = fmt.Sprintf("Pod %q failed", pod.Name) 113 } 114 target.State.Terminated = &session.TargetStateTerminated{ 115 StartTime: createdAt, 116 Error: podErr, 117 GraceStatus: graceStatus, 118 } 119 return target 120 } 121 122 for _, ctr := range store.AllPodContainers(pod) { 123 if k8sconv.ContainerStatusToRuntimeState(ctr) == v1alpha1.RuntimeStatusError { 124 target.State.Terminated = &session.TargetStateTerminated{ 125 StartTime: apis.NewMicroTime(pod.CreatedAt.Time), 126 Error: fmt.Sprintf("Pod %s in error state due to container %s: %s", 127 pod.Name, ctr.Name, pod.Status), 128 GraceStatus: graceStatus, 129 } 130 return target 131 } 132 } 133 134 target.State.Terminated = &session.TargetStateTerminated{ 135 StartTime: createdAt, 136 Error: "unknown error", 137 GraceStatus: graceStatus, 138 } 139 return target 140 } 141 142 if status == v1alpha1.RuntimeStatusPending { 143 if v1.PodRunning == phase { 144 target.State.Active = &session.TargetStateActive{ 145 StartTime: createdAt, 146 Ready: false, 147 LastReadyTime: lastReadyTime, 148 } 149 return target 150 } 151 152 waitReason := pod.Status 153 if waitReason == "" { 154 if pod.Name == "" { 155 waitReason = "waiting-for-pod" 156 } else { 157 waitReason = "unknown" 158 } 159 } 160 target.State.Waiting = &session.TargetStateWaiting{ 161 WaitReason: waitReason, 162 } 163 } 164 165 return target 166 } 167 168 func (r *Reconciler) localServeTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet) *session.Target { 169 if mt.Manifest.LocalTarget().ServeCmd.Empty() { 170 // there is no serve_cmd, so don't return a runtime target at all 171 // (there will still be a build target from the update cmd) 172 return nil 173 } 174 175 target := &session.Target{ 176 Name: fmt.Sprintf("%s:serve", mt.Manifest.Name.String()), 177 Resources: []string{mt.Manifest.Name.String()}, 178 Type: session.TargetTypeServer, 179 } 180 181 if mt.State.DisableState == session.DisableStateDisabled { 182 target.State.Disabled = &session.TargetStateDisabled{} 183 return target 184 } 185 186 lrs := mt.State.LocalRuntimeState() 187 lastReadyTime := apis.NewMicroTime(lrs.LastReadyOrSucceededTime) 188 if runtimeErr := lrs.RuntimeStatusError(); runtimeErr != nil { 189 target.State.Terminated = &session.TargetStateTerminated{ 190 StartTime: apis.NewMicroTime(lrs.StartTime), 191 FinishTime: apis.NewMicroTime(lrs.FinishTime), 192 Error: errToString(runtimeErr), 193 } 194 } else if lrs.PID != 0 { 195 target.State.Active = &session.TargetStateActive{ 196 StartTime: apis.NewMicroTime(lrs.StartTime), 197 Ready: lrs.Ready, 198 LastReadyTime: lastReadyTime, 199 } 200 } else if mt.Manifest.TriggerMode.AutoInitial() || mt.State.StartedFirstBuild() { 201 // default to waiting unless this resource has auto_init=False and has never 202 // had a build triggered for other reasons (e.g. trigger_mode=TRIGGER_MODE_AUTO and 203 // a relevant file change or being manually invoked via UI) 204 // the latter case ensures there's no race condition between a build being 205 // triggered and the local process actually being launched 206 // 207 // otherwise, Terminated/Active/Waiting will all be nil, which indicates that 208 // the target is currently inactive 209 target.State.Waiting = waitingFromHolds(mt.Manifest.Name, holds) 210 } 211 212 return target 213 } 214 215 // genericRuntimeTarget creates a target from the RuntimeState interface without any domain-specific considerations. 216 // 217 // This is both used for target types that don't require specialized logic (Docker Compose) as well as a fallback for 218 // any new types that don't have deeper support here. 219 func (r *Reconciler) genericRuntimeTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet) *session.Target { 220 target := &session.Target{ 221 Name: fmt.Sprintf("%s:runtime", mt.Manifest.Name.String()), 222 Resources: []string{mt.Manifest.Name.String()}, 223 Type: session.TargetTypeServer, 224 } 225 226 if mt.State.DisableState == session.DisableStateDisabled { 227 target.State.Disabled = &session.TargetStateDisabled{} 228 return target 229 } 230 231 runtimeStatus := mt.RuntimeStatus() 232 switch runtimeStatus { 233 case v1alpha1.RuntimeStatusPending: 234 target.State.Waiting = waitingFromHolds(mt.Manifest.Name, holds) 235 case v1alpha1.RuntimeStatusOK: 236 target.State.Active = &session.TargetStateActive{ 237 StartTime: apis.NewMicroTime(mt.State.LastSuccessfulDeployTime), 238 // generic resources have no readiness concept so they're just ready by default 239 // (this also applies to Docker Compose, since we don't support its health checks) 240 Ready: true, 241 LastReadyTime: apis.NewMicroTime(mt.State.LastSuccessfulDeployTime), 242 } 243 case v1alpha1.RuntimeStatusError: 244 errMsg := errToString(mt.State.RuntimeState.RuntimeStatusError()) 245 if errMsg == "" { 246 errMsg = "Server target %q failed" 247 } 248 target.State.Terminated = &session.TargetStateTerminated{ 249 Error: errMsg, 250 } 251 } 252 253 return target 254 } 255 256 func (r *Reconciler) runtimeTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet, ci *v1alpha1.SessionCISpec, result *ctrl.Result) *session.Target { 257 if mt.Manifest.IsK8s() { 258 return r.k8sRuntimeTarget(mt, ci, result) 259 } else if mt.Manifest.IsLocal() { 260 return r.localServeTarget(mt, holds) 261 } else { 262 return r.genericRuntimeTarget(mt, holds) 263 } 264 } 265 266 // buildTarget creates a "build" (or update) target for the resource. 267 // 268 // Currently, the engine aggregates many different targets into a single build record, and that's reflected here. 269 // Ideally, as the internals change, more granularity will provided and this might actually return a slice of targets 270 // rather than a single target. For example, a K8s resource might have an image build step and then a deployment (i.e. 271 // kubectl apply) step - currently, both of these will be aggregated together, which can make it harder to diagnose 272 // where something is stuck or slow. 273 func buildTarget(mt *store.ManifestTarget, holds buildcontrol.HoldSet) *session.Target { 274 if mt.Manifest.IsLocal() && mt.Manifest.LocalTarget().UpdateCmdSpec == nil { 275 return nil 276 } 277 278 res := &session.Target{ 279 Name: fmt.Sprintf("%s:update", mt.Manifest.Name.String()), 280 Resources: []string{mt.Manifest.Name.String()}, 281 Type: session.TargetTypeJob, 282 } 283 284 if mt.State.DisableState == session.DisableStateDisabled { 285 res.State.Disabled = &session.TargetStateDisabled{} 286 return res 287 } 288 289 isPending := mt.NextBuildReason() != model.BuildReasonNone 290 currentBuild := mt.State.EarliestCurrentBuild() 291 if isPending { 292 res.State.Waiting = waitingFromHolds(mt.Manifest.Name, holds) 293 } else if !currentBuild.Empty() { 294 res.State.Active = &session.TargetStateActive{ 295 StartTime: apis.NewMicroTime(currentBuild.StartTime), 296 } 297 } else if len(mt.State.BuildHistory) != 0 { 298 lastBuild := mt.State.LastBuild() 299 res.State.Terminated = &session.TargetStateTerminated{ 300 StartTime: apis.NewMicroTime(lastBuild.StartTime), 301 FinishTime: apis.NewMicroTime(lastBuild.FinishTime), 302 Error: errToString(lastBuild.Error), 303 } 304 } 305 306 return res 307 } 308 309 func k8sTargetType(mt *store.ManifestTarget) session.TargetType { 310 if !mt.Manifest.IsK8s() { 311 return "" 312 } 313 314 krs := mt.State.K8sRuntimeState() 315 if krs.PodReadinessMode == model.PodReadinessSucceeded { 316 return session.TargetTypeJob 317 } 318 319 return session.TargetTypeServer 320 } 321 322 func waitingFromHolds(mn model.ManifestName, holds buildcontrol.HoldSet) *session.TargetStateWaiting { 323 // in the API, the reason is not _why_ the target "exists", but rather an explanation for why it's not yet 324 // active and is in a pending state (e.g. waitingFromHolds for dependencies) 325 waitReason := "unknown" 326 if hold, ok := holds[mn]; ok && hold.Reason != store.HoldReasonNone { 327 waitReason = string(hold.Reason) 328 } 329 return &session.TargetStateWaiting{ 330 WaitReason: waitReason, 331 } 332 } 333 334 // tiltfileTarget creates a session.Target object from a Tiltfile ManifestState 335 // 336 // This is slightly different from generic resource handling because there is no 337 // ManifestTarget in the engine for the Tiltfile (just ManifestState) and config 338 // file changes are stored stop level on state, but conceptually it does similar 339 // things. 340 func tiltfileTarget(name model.ManifestName, ms *store.ManifestState) session.Target { 341 target := session.Target{ 342 Name: "tiltfile:update", 343 Resources: []string{name.String()}, 344 Type: session.TargetTypeJob, 345 } 346 347 // Tiltfile is special in engine state and doesn't have a target, just state, so 348 // this logic is largely duplicated from the generic resource build logic 349 if ms.IsBuilding() { 350 target.State.Active = &session.TargetStateActive{ 351 StartTime: apis.NewMicroTime(ms.EarliestCurrentBuild().StartTime), 352 } 353 } else if hasPendingChanges, _ := ms.HasPendingChanges(); hasPendingChanges { 354 target.State.Waiting = &session.TargetStateWaiting{ 355 WaitReason: "config-changed", 356 } 357 } else if len(ms.BuildHistory) != 0 { 358 lastBuild := ms.LastBuild() 359 target.State.Terminated = &session.TargetStateTerminated{ 360 StartTime: apis.NewMicroTime(lastBuild.StartTime), 361 FinishTime: apis.NewMicroTime(lastBuild.FinishTime), 362 Error: errToString(lastBuild.Error), 363 } 364 } else { 365 // given the current engine behavior, this doesn't actually occur because 366 // the first build happens as part of initialization 367 target.State.Waiting = &session.TargetStateWaiting{ 368 WaitReason: "initial-build", 369 } 370 } 371 372 return target 373 }