Skip to content

Commit

Permalink
session: use apiserver data model in exit controller (#4367)
Browse files Browse the repository at this point in the history
This is the first increment towards moving towards a reconciler;
the controller uses the API server `TiltRun` data model internally
now and updates it, so it can be accessed externally:
```
KUBECONFIG=$(tilt alpha kubeconfig-path) kubectl describe tiltrun Tiltfile
```

NOTE: `TiltRun` is imminently being renamed `Session` so this
             is pretty much the only commit you'll see it in ;)
  • Loading branch information
milas committed Apr 7, 2021
1 parent 8588890 commit fbb3039
Show file tree
Hide file tree
Showing 13 changed files with 1,167 additions and 614 deletions.
4 changes: 2 additions & 2 deletions internal/cli/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 36 additions & 4 deletions internal/engine/exit/actions.go
@@ -1,8 +1,40 @@
package exit

type Action struct {
ExitSignal bool
ExitError error
import (
"errors"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"

"github.com/tilt-dev/tilt/internal/store"
tiltruns "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
)

type TiltRunUpdateStatusAction struct {
ObjectMeta *metav1.ObjectMeta
Status *tiltruns.TiltRunStatus
}

var _ store.Summarizer = TiltRunUpdateStatusAction{}

func (a TiltRunUpdateStatusAction) Summarize(summary *store.ChangeSummary) {
summary.TiltRuns.Add(types.NamespacedName{Namespace: a.ObjectMeta.Namespace, Name: a.ObjectMeta.Name})
}

func NewTiltRunUpdateStatusAction(tiltRun *tiltruns.TiltRun) TiltRunUpdateStatusAction {
return TiltRunUpdateStatusAction{
ObjectMeta: tiltRun.ObjectMeta.DeepCopy(),
Status: tiltRun.Status.DeepCopy(),
}
}

func (Action) Action() {}
func (TiltRunUpdateStatusAction) Action() {}

func HandleTiltRunUpdateStatusAction(state *store.EngineState, action TiltRunUpdateStatusAction) {
if action.Status.Done {
state.ExitSignal = true
if action.Status.Error != "" {
state.ExitError = errors.New(action.Status.Error)
}
}
}
253 changes: 166 additions & 87 deletions internal/engine/exit/controller.go
Expand Up @@ -2,125 +2,204 @@ package exit

import (
"context"
"fmt"
"os"
"sort"
"sync"
"time"

v1 "k8s.io/api/core/v1"
"github.com/tilt-dev/tilt/internal/engine/buildcontrol"

"github.com/tilt-dev/tilt/pkg/logger"

"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"

"github.com/tilt-dev/tilt/internal/store"
"github.com/tilt-dev/tilt/pkg/model"
tiltrun "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
)

// Controls normal process termination. Either Tilt completed all its work,
// Controller handles normal process termination. Either Tilt completed all its work,
// or it determined that it was unable to complete the work it was assigned.
type Controller struct {
}
pid int64
startTime time.Time
client ctrlclient.Client

func NewController() *Controller {
return &Controller{}
mu sync.Mutex
tiltRun *tiltrun.TiltRun
}

func (c *Controller) shouldExit(store store.RStore) Action {
state := store.RLockState()
defer store.RUnlockState()
var _ store.Subscriber = &Controller{}

// If state already has an ExitSignal or engine is NOT in CI mode (i.e. it's in interactive "Up" mode),
// there's nothing to do
if state.ExitSignal || !state.EngineMode.IsCIMode() {
return Action{}
func NewController(cli ctrlclient.Client) *Controller {
return &Controller{
pid: int64(os.Getpid()),
startTime: time.Now(),
client: cli,
}
}

// If the tiltfile failed, exit immediately.
err := state.TiltfileState.LastBuild().Error
if err != nil {
return Action{ExitSignal: true, ExitError: err}
func (c *Controller) OnChange(ctx context.Context, st store.RStore, summary store.ChangeSummary) {
if summary.IsLogOnly() {
return
}

// If any of the individual builds failed, exit immediately.
for _, mt := range state.ManifestTargets {
err := mt.State.LastBuild().Error
if err != nil {
return Action{ExitSignal: true, ExitError: err}
c.mu.Lock()
defer c.mu.Unlock()

if c.tiltRun == nil {
if initialized, err := c.initialize(ctx, st); err != nil {
st.Dispatch(store.NewErrorAction(fmt.Errorf("failed to initialize ExitController: %v", err)))
return
} else if !initialized {
// engine is still starting up, no-op until ready for initialization
return
}
}

// Check the runtime state of all resources.
// If any of the resources are in error, exit.
allOK := true
for _, mt := range state.ManifestTargets {
// don't wait on resources requiring manual trigger for initial build
if !mt.Manifest.TriggerMode.AutoInitial() {
continue
}
newStatus := c.makeLatestStatus(st)
if err := c.handleLatestStatus(ctx, st, newStatus); err != nil {
logger.Get(ctx).Debugf("failed to update TiltRun status: %v", err)
}
}

rs := mt.State.RuntimeState
if rs == nil {
allOK = false
continue
}
func (c *Controller) initialize(ctx context.Context, st store.RStore) (bool, error) {
tiltRun := c.makeTiltRun(st)
if tiltRun == nil {
return false, nil
}

status := rs.RuntimeStatus()
if status == model.RuntimeStatusError {
return Action{
ExitSignal: true,
ExitError: rs.RuntimeStatusError(),
}
}
// TODO(milas): rather than implicitly creating the TiltRun object here, it should
// be created explicitly as part of loading the Tiltfile
if err := c.client.Create(ctx, tiltRun); err != nil {
return false, fmt.Errorf("failed to create TiltRun API object: %v", err)
}

if !c.isRuntimeDone(mt) {
allOK = false
}
c.tiltRun = tiltRun

return true, nil
}

func (c *Controller) makeTiltRun(st store.RStore) *tiltrun.TiltRun {
state := st.RLockState()
defer st.RUnlockState()

// engine hasn't finished initialization - Tiltfile hasn't been loaded yet
if state.TiltfilePath == "" {
return nil
}

// If all the resources are OK, we're done.
if len(state.ManifestTargets) > 0 &&
state.InitialBuildsCompleted() && allOK {
return Action{ExitSignal: true}
tiltRun := &tiltrun.TiltRun{
ObjectMeta: metav1.ObjectMeta{
Name: "Tiltfile",
},
Spec: tiltrun.TiltRunSpec{
TiltfilePath: state.TiltfilePath,
},
Status: tiltrun.TiltRunStatus{
PID: c.pid,
StartTime: metav1.NewMicroTime(c.startTime),
},
}

return Action{}
// currently, manual + CI are the only supported modes; the apiserver will validate this field and reject
// the object on creation if it doesn't conform, so there's no additional validation/error-handling here
switch state.EngineMode {
case store.EngineModeUp:
tiltRun.Spec.ExitCondition = tiltrun.ExitConditionManual
case store.EngineModeCI:
tiltRun.Spec.ExitCondition = tiltrun.ExitConditionCI
}

return tiltRun
}

func (c *Controller) isRuntimeDone(mt *store.ManifestTarget) bool {
rs := mt.State.RuntimeState
if rs == nil {
return false
}

status := rs.RuntimeStatus()
statusOK := status == model.RuntimeStatusOK || status == model.RuntimeStatusNotApplicable
if !statusOK {
return false
}

// If this is a job, check to see if it has run to completion
//
// TODO(nick): This is...not great. Ideally, Tilt would track the status of
// every resource type it deploys, then we'd have some general-purpose system
// for expressing success criteria on different resource types (like
// https://www.openpolicyagent.org/). This is just a hack to make this work
// for jobs, until it makes sense to build out that type-independent
// infrastructure.
isK8s := mt.Manifest.IsK8s()
isK8sJob := isK8s && mt.Manifest.K8sTarget().HasJob()
if isK8sJob {
k8sState, ok := mt.State.RuntimeState.(store.K8sRuntimeState)
if !ok {
return false
}
func (c *Controller) makeLatestStatus(st store.RStore) *tiltrun.TiltRunStatus {
state := st.RLockState()
defer st.RUnlockState()

pod := k8sState.MostRecentPod()
if pod.Phase != v1.PodSucceeded {
return false
}
status := &tiltrun.TiltRunStatus{
PID: c.pid,
StartTime: metav1.NewMicroTime(c.startTime),
}

return true
status.Targets = append(status.Targets, tiltfileTarget(state))

// determine the reason any resources (and thus all of their targets) are waiting (aka "holds")
// N.B. we don't actually care about what's "next" to build, but the info comes alongside that
_, holds := buildcontrol.NextTargetToBuild(state)

for _, mt := range state.ManifestTargets {
status.Targets = append(status.Targets, targetsForResource(mt, holds)...)
}
// ensure consistent ordering to avoid unnecessary updates
sort.SliceStable(status.Targets, func(i, j int) bool {
return status.Targets[i].Name < status.Targets[j].Name
})

processExitCondition(c.tiltRun.Spec.ExitCondition, status)
return status
}

func (c *Controller) OnChange(ctx context.Context, store store.RStore, _ store.ChangeSummary) {
action := c.shouldExit(store)
if action.ExitSignal {
store.Dispatch(action)
func (c *Controller) handleLatestStatus(ctx context.Context, st store.RStore, newStatus *tiltrun.TiltRunStatus) error {
if equality.Semantic.DeepEqual(&c.tiltRun.Status, newStatus) {
return nil
}

// deep copy is made to avoid tainting local version on failure
updated := c.tiltRun.DeepCopy()
updated.Status = *newStatus

if err := c.client.Status().Update(ctx, updated); err != nil {
return err
}

c.tiltRun = updated
st.Dispatch(NewTiltRunUpdateStatusAction(updated))

return nil
}

var _ store.Subscriber = &Controller{}
func processExitCondition(exitCondition tiltrun.ExitCondition, status *tiltrun.TiltRunStatus) {
if exitCondition == tiltrun.ExitConditionManual {
return
} else if exitCondition != tiltrun.ExitConditionCI {
status.Done = true
status.Error = fmt.Sprintf("unsupported exit condition: %s", exitCondition)
}

allResourcesOK := true
for _, res := range status.Targets {
if res.State.Waiting == nil && res.State.Active == nil && res.State.Terminated == nil {
// if all states are nil, the target has not been requested to run, e.g. auto_init=False
continue
}
if res.State.Terminated != nil && res.State.Terminated.Error != "" {
status.Done = true
status.Error = res.State.Terminated.Error
return
}
if res.State.Waiting != nil {
allResourcesOK = false
} else if res.State.Active != nil && (!res.State.Active.Ready || res.Type == tiltrun.TargetTypeJob) {
// jobs must run to completion
allResourcesOK = false
}
}

// Tiltfile is _always_ a target, so ensure that there's at least one other real target, or it's possible to
// exit before the targets have actually been initialized
if allResourcesOK && len(status.Targets) > 1 {
status.Done = true
}
}

// errToString returns a stringified version of an error or an empty string if the error is nil.
func errToString(err error) string {
if err == nil {
return ""
}
return err.Error()
}

0 comments on commit fbb3039

Please sign in to comment.