Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions internal/cli/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package cli

import (
"context"
"log"
"os"
"strconv"
"time"

"github.com/mattn/go-isatty"
"github.com/spf13/cobra"

"github.com/tilt-dev/tilt/internal/analytics"
engineanalytics "github.com/tilt-dev/tilt/internal/engine/analytics"
"github.com/tilt-dev/tilt/internal/hud/prompt"
"github.com/tilt-dev/tilt/internal/store"
"github.com/tilt-dev/tilt/pkg/logger"
"github.com/tilt-dev/tilt/pkg/model"
)

type attachCmd struct {
fileName string
outputSnapshotOnExit string

legacy bool
stream bool
}

func (c *attachCmd) name() model.TiltSubcommand { return "attach" }

func (c *attachCmd) register() *cobra.Command {
cmd := &cobra.Command{
Use: "attach [<tilt flags>] [-- <Tiltfile args>]",
DisableFlagsInUseLine: true,
Short: "Attach to existing resources without rebuilding",
Long: `
Starts Tilt and attaches to any resources that already exist on remote
without verifying the image hash. Resources that are missing are built
and deployed normally (identical to tilt up).

Accepts the same flags and Tiltfile args as tilt up.
`,
}

cmd.Flags().BoolVar(&c.legacy, "legacy", false, "If true, tilt will open in legacy terminal mode.")
cmd.Flags().BoolVar(&c.stream, "stream", false, "If true, tilt will stream logs in the terminal.")
cmd.Flags().BoolVar(&logActionsFlag, "logactions", false, "log all actions and state changes")
addStartServerFlags(cmd)
addDevServerFlags(cmd)
addTiltfileFlag(cmd, &c.fileName)
addKubeContextFlag(cmd)
addNamespaceFlag(cmd)
addLogFilterFlags(cmd, "log-")
addLogFilterResourcesFlag(cmd)
cmd.Flags().Lookup("logactions").Hidden = true
cmd.Flags().StringVar(&c.outputSnapshotOnExit, "output-snapshot-on-exit", "", "If specified, Tilt will dump a snapshot of its state to the specified path when it exits")

return cmd
}

func (c *attachCmd) initialTermMode(isTerminal bool) store.TerminalMode {
if !isTerminal {
return store.TerminalModeStream
}
if c.legacy {
return store.TerminalModeHUD
}
if c.stream {
return store.TerminalModeStream
}
return store.TerminalModePrompt
}

func (c *attachCmd) run(ctx context.Context, args []string) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

a := analytics.Get(ctx)
defer a.Flush(time.Second)

log.SetFlags(log.Flags() &^ (log.Ldate | log.Ltime))
isTTY := isatty.IsTerminal(os.Stdout.Fd())
termMode := c.initialTermMode(isTTY)

cmdTags := engineanalytics.CmdTags(map[string]string{
"term_mode": strconv.Itoa(int(termMode)),
})
a.Incr("cmd.attach", cmdTags.AsMap())

deferred := logger.NewDeferredLogger(ctx)
ctx = redirectLogs(ctx, deferred)

webHost := provideWebHost()
webURL, _ := provideWebURL(webHost, provideWebPort())
startLine := prompt.StartStatusLine(webURL, webHost)
log.Print(startLine)
log.Print(buildStamp())

if ok, reason := analytics.IsAnalyticsDisabledFromEnv(); ok {
log.Printf("Tilt analytics disabled: %s", reason)
}

cmdUpDeps, err := wireCmdUp(ctx, a, cmdTags, "attach")
if err != nil {
deferred.SetOutput(deferred.Original())
return err
}

upper := cmdUpDeps.Upper
if termMode == store.TerminalModePrompt {
cmdUpDeps.Prompt.SetInitOutput(deferred.CopyBuffered(logger.InfoLvl))
}

l := store.NewLogActionLogger(ctx, upper.Dispatch)
deferred.SetOutput(l)
ctx = redirectLogs(ctx, l)
if c.outputSnapshotOnExit != "" {
defer cmdUpDeps.Snapshotter.WriteSnapshot(ctx, c.outputSnapshotOnExit)
}

err = upper.Start(ctx, args, cmdUpDeps.TiltBuild,
c.fileName, termMode, a.UserOpt(), cmdUpDeps.Token, string(cmdUpDeps.CloudAddress), true)
if err != context.Canceled {
return err
}
return nil
}
2 changes: 1 addition & 1 deletion internal/cli/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (c *ciCmd) run(ctx context.Context, args []string) error {

err = upper.Start(ctx, args, cmdCIDeps.TiltBuild,
c.fileName, store.TerminalModeStream, a.UserOpt(), cmdCIDeps.Token,
string(cmdCIDeps.CloudAddress))
string(cmdCIDeps.CloudAddress), false)
if err == nil {
_, _ = fmt.Fprintln(colorable.NewColorableStdout(),
color.GreenString("SUCCESS. All workloads are healthy."))
Expand Down
1 change: 1 addition & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ up-to-date in real-time. Think 'docker build && kubectl apply' or 'docker-compos
}
streams := genericclioptions.IOStreams{Out: os.Stdout, ErrOut: os.Stderr, In: os.Stdin}

addCommand(rootCmd, &attachCmd{})
addCommand(rootCmd, &ciCmd{})
addCommand(rootCmd, &upCmd{})
addCommand(rootCmd, &dockerCmd{})
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ func (c *upCmd) run(ctx context.Context, args []string) error {
}

err = upper.Start(ctx, args, cmdUpDeps.TiltBuild,
c.fileName, termMode, a.UserOpt(), cmdUpDeps.Token, string(cmdUpDeps.CloudAddress))
c.fileName, termMode, a.UserOpt(), cmdUpDeps.Token, string(cmdUpDeps.CloudAddress), false)
if err != context.Canceled {
return err
} else {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/updog.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func (c *updogCmd) run(ctx context.Context, args []string) error {
// controllers registered.
err = deps.Upper.Start(ctx, args, deps.TiltBuild,
"Tiltfile", store.TerminalModeStream, a.UserOpt(), deps.Token,
string(deps.CloudAddress))
string(deps.CloudAddress), false)
if err != context.Canceled {
return err
} else {
Expand Down
1 change: 1 addition & 0 deletions internal/engine/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type InitAction struct {
CloudAddress string
Token token.Token
TerminalMode store.TerminalMode
AttachMode bool
}

func (InitAction) Action() {}
Expand Down
3 changes: 3 additions & 0 deletions internal/engine/upper.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func (u Upper) Start(
analyticsUserOpt analytics.Opt,
token token.Token,
cloudAddress string,
attachMode bool,
) error {

startTime := time.Now()
Expand All @@ -94,6 +95,7 @@ func (u Upper) Start(
Token: token,
CloudAddress: cloudAddress,
TerminalMode: initTerminalMode,
AttachMode: attachMode,
})
}

Expand Down Expand Up @@ -283,6 +285,7 @@ func handleInitAction(ctx context.Context, engineState *store.EngineState, actio
engineState.CloudAddress = action.CloudAddress
engineState.Token = action.Token
engineState.TerminalMode = action.TerminalMode
engineState.AttachMode = action.AttachMode
}

func handleHudExitAction(state *store.EngineState, action hud.ExitAction) {
Expand Down
4 changes: 2 additions & 2 deletions internal/engine/upper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2110,7 +2110,7 @@ func TestEmptyTiltfile(t *testing.T) {
err := f.upper.Start(f.ctx, []string{}, model.TiltBuild{},
f.JoinPath("Tiltfile"), store.TerminalModeHUD,
analytics.OptIn, token.Token("unit test token"),
"nonexistent.example.com")
"nonexistent.example.com", false)
closeCh <- err
}()
f.WaitUntil("build is set", func(st store.EngineState) bool {
Expand Down Expand Up @@ -2144,7 +2144,7 @@ func TestUpperStart(t *testing.T) {
go func() {
err := f.upper.Start(f.ctx, []string{"foo", "bar"}, model.TiltBuild{},
f.JoinPath("Tiltfile"), store.TerminalModeHUD,
analytics.OptIn, tok, cloudAddress)
analytics.OptIn, tok, cloudAddress, false)
closeCh <- err
}()
f.WaitUntil("init action processed", func(state store.EngineState) bool {
Expand Down
7 changes: 7 additions & 0 deletions internal/store/engine_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type EngineState struct {
TiltBuildInfo model.TiltBuild
TiltStartTime time.Time

// AttachMode indicates that tilt should blindly attach to existing
// deployments instead of rebuilding them. When a manifest's pods are
// discovered before its first build, a synthetic build record is injected
// so that tilt treats the manifest as already deployed. Resources that
// have no existing pods are built and deployed normally.
AttachMode bool

// saved so that we can render in order
ManifestDefinitionOrder []model.ManifestName

Expand Down
39 changes: 36 additions & 3 deletions internal/store/kubernetesdiscoverys/reducers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package kubernetesdiscoverys
import (
"time"

v1 "k8s.io/api/core/v1"

"github.com/tilt-dev/tilt/internal/controllers/apicmp"
"github.com/tilt-dev/tilt/internal/store"
"github.com/tilt-dev/tilt/internal/store/k8sconv"
Expand Down Expand Up @@ -79,7 +81,6 @@ func RefreshKubernetesResource(state *store.EngineState, name string) {
krs := ms.K8sRuntimeState()

if d == nil {
// if the KubernetesDiscovery goes away, we no longer know about any pods
krs.FilteredPods = nil
ms.RuntimeState = krs
return
Expand All @@ -89,12 +90,44 @@ func RefreshKubernetesResource(state *store.EngineState, name string) {
krs.Conditions = r.ApplyStatus.Conditions

if krs.RuntimeStatus() == v1alpha1.RuntimeStatusOK {
// NOTE(nick): It doesn't seem right to update this timestamp everytime
// we get a new event, but it's what the old code did.
krs.LastReadyOrSucceededTime = time.Now()
}

ms.RuntimeState = krs

maybeInjectAttachBuild(state, ms, r)
}
}
}

// maybeInjectAttachBuild injects a synthetic build record when running in
// attach mode. This makes tilt treat already-running pods as "already deployed"
// so it skips the initial image build and kubectl apply. Resources without
// running pods fall through to the normal build path.
func maybeInjectAttachBuild(state *store.EngineState, ms *store.ManifestState, r *k8sconv.KubernetesResource) {
if !state.AttachMode {
return
}
if ms.StartedFirstBuild() {
return
}
if !hasRunningPod(r.FilteredPods) {
return
}

now := time.Now()
ms.AddCompletedBuild(model.BuildRecord{
StartTime: now,
FinishTime: now,
Reason: model.BuildReasonFlagInit,
})
Comment on lines +117 to +123
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Attach mode never sets HasEverDeployedSuccessfully, leaving K8s resources permanently stuck in Pending status

The synthetic build injected by maybeInjectAttachBuild adds a BuildRecord to mark the manifest as "already built," but it never sets HasEverDeployedSuccessfully = true on the K8sRuntimeState. This is critical because K8sRuntimeState.RuntimeStatus() (internal/store/runtime_state.go:124-127) short-circuits to RuntimeStatusPending whenever HasEverDeployedSuccessfully is false.

Root Cause and Impact

In the normal build path, HasEverDeployedSuccessfully is set to true in HandleBuildCompleted at internal/store/buildcontrols/reducers.go:233. But in attach mode, the real build is skipped entirely — only a synthetic BuildRecord is injected at internal/store/kubernetesdiscoverys/reducers.go:118-123.

Without HasEverDeployedSuccessfully = true, the following cascade of failures occurs:

  1. krs.RuntimeStatus() always returns RuntimeStatusPending (see internal/store/runtime_state.go:125-127)
  2. The check at internal/store/kubernetesdiscoverys/reducers.go:92 (if krs.RuntimeStatus() == v1alpha1.RuntimeStatusOK) never passes, so LastReadyOrSucceededTime is never set
  3. HasEverBeenReadyOrSucceeded() (internal/store/runtime_state.go:164-166) also returns false
  4. The resource is permanently stuck in "Pending" runtime status in the UI and for session/CI health checks

This effectively makes the entire tilt attach command non-functional for Kubernetes resources — the exact resources it is designed to handle.

Suggested change
now := time.Now()
ms.AddCompletedBuild(model.BuildRecord{
StartTime: now,
FinishTime: now,
Reason: model.BuildReasonFlagInit,
})
now := time.Now()
ms.AddCompletedBuild(model.BuildRecord{
StartTime: now,
FinishTime: now,
Reason: model.BuildReasonFlagInit,
})
ms.LastSuccessfulDeployTime = now
krs := ms.K8sRuntimeState()
krs.HasEverDeployedSuccessfully = true
ms.RuntimeState = krs
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

func hasRunningPod(pods []v1alpha1.Pod) bool {
for i := range pods {
if pods[i].Phase == string(v1.PodRunning) {
return true
}
}
return false
}