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  }