Skip to content

Commit

Permalink
tiltrun: use apiserver data model in exit controller
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
```
  • Loading branch information
milas committed Apr 2, 2021
1 parent 97649c5 commit 3e0dcf8
Show file tree
Hide file tree
Showing 13 changed files with 1,076 additions and 605 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
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
250 changes: 164 additions & 86 deletions internal/engine/exit/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,125 +2,203 @@ 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"
tiltruns "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 *tiltruns.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
newStatus := c.makeLatestStatus(st)
if err := c.handleLatestStatus(ctx, st, newStatus); err != nil {
logger.Get(ctx).Debugf("failed to update TiltRun status: %v", err)
}
}

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

// 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)
}

c.tiltRun = tiltRun

return true, nil
}

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

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

tiltRun := &tiltruns.TiltRun{
ObjectMeta: metav1.ObjectMeta{
Name: "Tiltfile",
},
Spec: tiltruns.TiltRunSpec{
TiltfilePath: state.TiltfilePath,
},
Status: tiltruns.TiltRunStatus{
PID: c.pid,
StartTime: metav1.NewMicroTime(c.startTime),
},
}

switch state.EngineMode {
case store.EngineModeUp:
tiltRun.Spec.ExitCondition = tiltruns.ExitConditionManual
case store.EngineModeCI:
tiltRun.Spec.ExitCondition = tiltruns.ExitConditionCI
}

return tiltRun
}

func (c *Controller) makeLatestStatus(st store.RStore) *tiltruns.TiltRunStatus {
state := st.RLockState()
defer st.RUnlockState()

status := &tiltruns.TiltRunStatus{
PID: c.pid,
StartTime: metav1.NewMicroTime(c.startTime),
}

tiltfileResource := tiltfileTarget(state.TiltfileState)

_, holds := buildcontrol.NextTargetToBuild(state)

var targetResources []tiltruns.Target
for _, mt := range state.ManifestTargets {
// don't wait on resources requiring manual trigger for initial build
if !mt.Manifest.TriggerMode.AutoInitial() {
continue
}
targetResources = append(targetResources, targetsForResource(mt, holds)...)
}
// ensure consistent ordering to avoid unnecessary updates
sort.SliceStable(targetResources, func(i, j int) bool {
return targetResources[i].Name < targetResources[j].Name
})

rs := mt.State.RuntimeState
if rs == nil {
allOK = false
continue
}
status.Targets = append([]tiltruns.Target{tiltfileResource}, targetResources...)

status := rs.RuntimeStatus()
if status == model.RuntimeStatusError {
return Action{
ExitSignal: true,
ExitError: rs.RuntimeStatusError(),
}
}
processExitCondition(c.tiltRun.Spec.ExitCondition, status)
return status
}

if !c.isRuntimeDone(mt) {
allOK = false
}
func (c *Controller) handleLatestStatus(ctx context.Context, st store.RStore, newStatus *tiltruns.TiltRunStatus) error {
if equality.Semantic.DeepEqual(c.tiltRun.Status, newStatus) {
return nil
}

// If all the resources are OK, we're done.
if len(state.ManifestTargets) > 0 &&
state.InitialBuildsCompleted() && allOK {
return Action{ExitSignal: true}
// 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
}

return Action{}
c.tiltRun = updated
st.Dispatch(NewTiltRunUpdateStatusAction(updated))

return nil
}

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 processExitCondition(exitCondition tiltruns.ExitCondition, status *tiltruns.TiltRunStatus) {
if exitCondition == tiltruns.ExitConditionManual {
return
} else if exitCondition != tiltruns.ExitConditionCI {
status.Done = true
status.Error = fmt.Sprintf("unsupported exit condition: %s", exitCondition)
}

pod := k8sState.MostRecentPod()
if pod.Phase != v1.PodSucceeded {
return false
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 == tiltruns.TargetTypeJob) {
// jobs must run to completion
allResourcesOK = false
}
}

return true
// 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
}
}

func (c *Controller) OnChange(ctx context.Context, store store.RStore, _ store.ChangeSummary) {
action := c.shouldExit(store)
if action.ExitSignal {
store.Dispatch(action)
// 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()
}

var _ store.Subscriber = &Controller{}
Loading

0 comments on commit 3e0dcf8

Please sign in to comment.