github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/engine/local/servercontroller.go (about) 1 package local 2 3 import ( 4 "context" 5 "fmt" 6 "sync" 7 "time" 8 9 "k8s.io/apimachinery/pkg/api/equality" 10 apierrors "k8s.io/apimachinery/pkg/api/errors" 11 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" 13 14 "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" 15 "github.com/tilt-dev/tilt/internal/store" 16 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 17 "github.com/tilt-dev/tilt/pkg/logger" 18 "github.com/tilt-dev/tilt/pkg/model/logstore" 19 ) 20 21 const AnnotationOwnerName = "tilt.dev/owner-name" 22 const AnnotationOwnerKind = "tilt.dev/owner-kind" 23 24 // Expresses the status of a build dependency. 25 const AnnotationDepStatus = "tilt.dev/dep-status" 26 27 // A controller that reads the Tilt data model and creates new Cmd objects. 28 // 29 // Reads the Cmd Status. 30 // 31 // A CmdServer offers two constraints on top of a Cmd: 32 // 33 // - We ensure that the old Cmd is terminated before we replace it 34 // with a new one, because they likely use the same port. 35 // 36 // - We report the Cmd status Terminated as an Error state, 37 // and report it in a standard way. 38 type ServerController struct { 39 recentlyCreatedCmd map[string]string 40 createdTriggerTime map[string]time.Time 41 client ctrlclient.Client 42 43 // store latest copies of CmdServer to allow introspection by tests 44 // via a substitute for a `GET` API endpoint 45 // TODO - remove when CmdServer is added to the API 46 mu sync.Mutex 47 cmdServers map[string]CmdServer 48 49 cmdCount int 50 } 51 52 var _ store.Subscriber = &ServerController{} 53 54 func NewServerController(client ctrlclient.Client) *ServerController { 55 return &ServerController{ 56 recentlyCreatedCmd: make(map[string]string), 57 createdTriggerTime: make(map[string]time.Time), 58 client: client, 59 } 60 } 61 62 func (c *ServerController) upsert(server CmdServer) { 63 c.mu.Lock() 64 defer c.mu.Unlock() 65 c.cmdServers[server.Name] = server 66 } 67 68 func (c *ServerController) OnChange(ctx context.Context, st store.RStore, summary store.ChangeSummary) error { 69 if summary.IsLogOnly() { 70 return nil 71 } 72 73 servers, owned, orphans := c.determineServers(ctx, st) 74 c.mu.Lock() 75 c.cmdServers = make(map[string]CmdServer) 76 c.mu.Unlock() 77 for _, server := range servers { 78 c.upsert(server) 79 } 80 81 for i, server := range servers { 82 c.reconcile(ctx, server, owned[i], st) 83 } 84 85 // Garbage collect commands where the owner has been deleted. 86 for _, orphan := range orphans { 87 c.deleteOrphanedCmd(ctx, st, orphan) 88 } 89 return nil 90 } 91 92 // Returns a list of server objects and the Cmd they own (if any). 93 func (c *ServerController) determineServers(ctx context.Context, st store.RStore) (servers []CmdServer, owned [][]*Cmd, orphaned []*Cmd) { 94 state := st.RLockState() 95 defer st.RUnlockState() 96 97 // Find all the Cmds owned by CmdServer. 98 // 99 // Simulates controller-runtime's notion of Owns(). 100 ownedCmds := make(map[string][]*Cmd) 101 for _, cmd := range state.Cmds { 102 ownerName := cmd.Annotations[AnnotationOwnerName] 103 ownerKind := cmd.Annotations[AnnotationOwnerKind] 104 if ownerKind != "CmdServer" { 105 continue 106 } 107 108 ownedCmds[ownerName] = append(ownedCmds[ownerName], cmd) 109 } 110 111 // Infer all the CmdServer objects from the legacy EngineState 112 for _, mt := range state.Targets() { 113 if !mt.Manifest.IsLocal() { 114 continue 115 } 116 lt := mt.Manifest.LocalTarget() 117 if lt.ServeCmd.Empty() { 118 continue 119 } 120 121 name := mt.Manifest.Name.String() 122 cmdServer := CmdServer{ 123 TypeMeta: metav1.TypeMeta{ 124 Kind: "CmdServer", 125 APIVersion: "tilt.dev/v1alpha1", 126 }, 127 ObjectMeta: ObjectMeta{ 128 Name: name, 129 Annotations: map[string]string{ 130 v1alpha1.AnnotationManifest: string(mt.Manifest.Name), 131 AnnotationDepStatus: string(mt.UpdateStatus()), 132 }, 133 }, 134 Spec: CmdServerSpec{ 135 Args: lt.ServeCmd.Argv, 136 Dir: lt.ServeCmd.Dir, 137 Env: lt.ServeCmd.Env, 138 TriggerTime: mt.State.LastSuccessfulDeployTime, 139 ReadinessProbe: lt.ReadinessProbe, 140 DisableSource: lt.ServeCmdDisableSource, 141 }, 142 } 143 144 mn := mt.Manifest.Name.String() 145 cmds, ok := ownedCmds[mn] 146 if ok { 147 delete(ownedCmds, mn) 148 } 149 150 servers = append(servers, cmdServer) 151 owned = append(owned, cmds) 152 } 153 154 for _, orphan := range ownedCmds { 155 orphaned = append(orphaned, orphan...) 156 } 157 158 return servers, owned, orphaned 159 } 160 161 // approximate a `GET` API endpoint for CmdServer 162 // TODO: remove once CmdServer is in the API 163 func (c *ServerController) Get(name string) CmdServer { 164 c.mu.Lock() 165 defer c.mu.Unlock() 166 result, ok := c.cmdServers[name] 167 if !ok { 168 return CmdServer{} 169 } 170 return result 171 } 172 173 // Find the most recent command in a collection 174 func (c *ServerController) mostRecentCmd(cmds []*Cmd) *Cmd { 175 var mostRecentCmd *Cmd 176 for _, cmd := range cmds { 177 if mostRecentCmd == nil { 178 mostRecentCmd = cmd 179 continue 180 } 181 182 if cmd.CreationTimestamp.Time.Equal(mostRecentCmd.CreationTimestamp.Time) { 183 if cmd.Name > mostRecentCmd.Name { 184 mostRecentCmd = cmd 185 } 186 continue 187 } 188 189 if cmd.CreationTimestamp.Time.After(mostRecentCmd.CreationTimestamp.Time) { 190 mostRecentCmd = cmd 191 } 192 } 193 194 return mostRecentCmd 195 } 196 197 // Delete a command and stop waiting on it. 198 func (c *ServerController) deleteOwnedCmd(ctx context.Context, serverName string, st store.RStore, cmd *Cmd) { 199 if waitingOn := c.recentlyCreatedCmd[serverName]; waitingOn == cmd.Name { 200 delete(c.recentlyCreatedCmd, serverName) 201 } 202 203 c.deleteOrphanedCmd(ctx, st, cmd) 204 } 205 206 // Delete an orphaned command. 207 func (c *ServerController) deleteOrphanedCmd(ctx context.Context, st store.RStore, cmd *Cmd) { 208 err := c.client.Delete(ctx, cmd) 209 210 // We want our reconciler to be idempotent, so it's OK 211 // if it deletes the same resource multiple times 212 if err != nil && !apierrors.IsNotFound(err) { 213 st.Dispatch(store.NewErrorAction(fmt.Errorf("deleting Cmd from apiserver: %v", err))) 214 return 215 } 216 217 st.Dispatch(CmdDeleteAction{Name: cmd.Name}) 218 } 219 220 func (c *ServerController) reconcile(ctx context.Context, server CmdServer, ownedCmds []*Cmd, st store.RStore) { 221 ctx = store.MustObjectLogHandler(ctx, st, &server) 222 name := server.Name 223 224 disableStatus, err := configmap.MaybeNewDisableStatus(ctx, c.client, server.Spec.DisableSource, server.Status.DisableStatus) 225 if err != nil { 226 st.Dispatch(store.NewErrorAction(fmt.Errorf("checking cmdserver disable status: %v", err))) 227 return 228 } 229 if disableStatus != server.Status.DisableStatus { 230 server.Status.DisableStatus = disableStatus 231 c.upsert(server) 232 } 233 if disableStatus.State == v1alpha1.DisableStateDisabled { 234 for _, cmd := range ownedCmds { 235 logger.Get(ctx).Infof("Resource is disabled, stopping cmd %q", cmd.Spec.Args) 236 c.deleteOwnedCmd(ctx, name, st, cmd) 237 } 238 return 239 } 240 241 // Do not make any changes to the server while the update status is building. 242 // This ensures the old server stays up while any deps are building. 243 depStatus := v1alpha1.UpdateStatus(server.ObjectMeta.Annotations[AnnotationDepStatus]) 244 if depStatus != v1alpha1.UpdateStatusOK && depStatus != v1alpha1.UpdateStatusNotApplicable { 245 return 246 } 247 248 // If the command was created recently but hasn't appeared yet, wait until it appears. 249 if waitingOn, ok := c.recentlyCreatedCmd[name]; ok { 250 seen := false 251 for _, owned := range ownedCmds { 252 if waitingOn == owned.Name { 253 seen = true 254 } 255 } 256 257 if !seen { 258 return 259 } 260 delete(c.recentlyCreatedCmd, name) 261 } 262 263 cmdSpec := CmdSpec{ 264 Args: server.Spec.Args, 265 Dir: server.Spec.Dir, 266 Env: server.Spec.Env, 267 ReadinessProbe: server.Spec.ReadinessProbe, 268 } 269 270 triggerTime := c.createdTriggerTime[name] 271 mostRecent := c.mostRecentCmd(ownedCmds) 272 if mostRecent != nil && equality.Semantic.DeepEqual(mostRecent.Spec, cmdSpec) && triggerTime.Equal(server.Spec.TriggerTime) { 273 // We're in the correct state! Nothing to do. 274 return 275 } 276 277 // Otherwise, we need to create a new command. 278 279 // Garbage collect all owned commands. 280 for _, owned := range ownedCmds { 281 c.deleteOwnedCmd(ctx, name, st, owned) 282 } 283 284 // If any commands are still running, we need to wait. 285 // Otherwise, we'll run into problems where a new server will 286 // start while the old server is hanging onto the port. 287 for _, owned := range ownedCmds { 288 if owned.Status.Terminated == nil { 289 return 290 } 291 } 292 293 // Start the command! 294 c.createdTriggerTime[name] = server.Spec.TriggerTime 295 c.cmdCount++ 296 297 cmdName := fmt.Sprintf("%s-serve-%d", name, c.cmdCount) 298 spanID := SpanIDForServeLog(c.cmdCount) 299 300 cmd := &Cmd{ 301 ObjectMeta: ObjectMeta{ 302 Name: cmdName, 303 Annotations: map[string]string{ 304 // TODO(nick): This should be an owner reference once CmdServer is a 305 // full-fledged type. 306 AnnotationOwnerName: name, 307 AnnotationOwnerKind: "CmdServer", 308 309 v1alpha1.AnnotationManifest: name, 310 v1alpha1.AnnotationSpanID: string(spanID), 311 }, 312 }, 313 Spec: cmdSpec, 314 } 315 c.recentlyCreatedCmd[name] = cmdName 316 317 err = c.client.Create(ctx, cmd) 318 if err != nil && !apierrors.IsNotFound(err) { 319 st.Dispatch(store.NewErrorAction(fmt.Errorf("syncing to apiserver: %v", err))) 320 return 321 } 322 323 st.Dispatch(CmdCreateAction{Cmd: cmd}) 324 } 325 326 type CmdServer struct { 327 metav1.TypeMeta 328 metav1.ObjectMeta 329 330 Spec CmdServerSpec 331 Status CmdServerStatus 332 } 333 334 type CmdServerSpec struct { 335 Args []string 336 Dir string 337 Env []string 338 ReadinessProbe *v1alpha1.Probe 339 340 // Kubernetes tends to represent this as a "generation" field 341 // to force an update. 342 TriggerTime time.Time 343 344 DisableSource *v1alpha1.DisableSource 345 } 346 347 type CmdServerStatus struct { 348 DisableStatus *v1alpha1.DisableStatus 349 } 350 351 func SpanIDForServeLog(procNum int) logstore.SpanID { 352 return logstore.SpanID(fmt.Sprintf("localserve:%d", procNum)) 353 }