From 6afa64a58193709c0853b4468004934deaa45a28 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Sat, 4 Apr 2026 20:07:37 +0300 Subject: [PATCH 01/14] api: add InitialSync type to LiveUpdateSpec Signed-off-by: arnas dundulis --- pkg/apis/core/v1alpha1/liveupdate_types.go | 50 +++++++++++++++++++ .../core/v1alpha1/zz_generated.deepcopy.go | 26 ++++++++++ .../core/v1alpha1/zz_generated.model_name.go | 5 ++ pkg/openapi/zz_generated.openapi.go | 44 +++++++++++++++- 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/pkg/apis/core/v1alpha1/liveupdate_types.go b/pkg/apis/core/v1alpha1/liveupdate_types.go index cd2943c811..133cb19bcf 100644 --- a/pkg/apis/core/v1alpha1/liveupdate_types.go +++ b/pkg/apis/core/v1alpha1/liveupdate_types.go @@ -19,6 +19,7 @@ package v1alpha1 import ( "context" "path" + "path/filepath" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -110,6 +111,13 @@ type LiveUpdateSpec struct { // // +optional Restart LiveUpdateRestartStrategy `json:"restart,omitempty" protobuf:"bytes,7,opt,name=restart,casttype=LiveUpdateRestartStrategy"` + + // InitialSync configures full file sync on container start/restart. + // When set, all files matching sync rules are uploaded when a container + // starts for the first time or restarts, bypassing the file-watch system. + // + // +optional + InitialSync *LiveUpdateInitialSync `json:"initialSync,omitempty" protobuf:"bytes,8,opt,name=initialSync"` } var _ resource.Object = &LiveUpdate{} @@ -208,6 +216,28 @@ func (in *LiveUpdate) Validate(ctx context.Context) field.ErrorList { } } + // Validate InitialSync fields + if in.Spec.InitialSync != nil { + initialSyncPath := field.NewPath("spec", "initialSync") + + // Validate ignore paths are relative (not absolute) + ignorePathsField := initialSyncPath.Child("ignorePaths") + for i, ignorePath := range in.Spec.InitialSync.IgnorePaths { + if filepath.IsAbs(ignorePath) { + errors = append(errors, + field.Invalid(ignorePathsField.Index(i), ignorePath, + "ignore paths must be relative to basePath")) + } + } + + // Validate dockerignore path is relative + if in.Spec.InitialSync.Dockerignore != "" && filepath.IsAbs(in.Spec.InitialSync.Dockerignore) { + errors = append(errors, + field.Invalid(initialSyncPath.Child("dockerignore"), in.Spec.InitialSync.Dockerignore, + "dockerignore path must be relative to basePath")) + } + } + return errors } @@ -378,6 +408,26 @@ var ( LiveUpdateRestartStrategyAlways LiveUpdateRestartStrategy = "always" ) +// LiveUpdateInitialSync configures initial sync behavior +type LiveUpdateInitialSync struct { + // IgnorePaths is a list of relative paths (relative to BasePath) + // to exclude from initial sync. These paths will still be synced + // on subsequent file changes. + // + // Supports exact matches and directory prefixes: + // - 'node_modules' excludes the node_modules directory + // - 'file.txt' excludes that specific file + // + // +optional + IgnorePaths []string `json:"ignorePaths,omitempty" protobuf:"bytes,1,rep,name=ignorePaths"` + + // Dockerignore is a path to a directory containing a .dockerignore file + // to apply during initial sync. If empty, no .dockerignore is loaded. + // + // +optional + Dockerignore string `json:"dockerignore,omitempty" protobuf:"bytes,2,opt,name=dockerignore"` +} + // LiveUpdateContainerStatus defines the observed state of // the live-update syncer for a particular container. type LiveUpdateContainerStatus struct { diff --git a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go index 03308e1ee4..bf6a7f69f4 100644 --- a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -2565,6 +2565,27 @@ func (in *LiveUpdateExec) DeepCopy() *LiveUpdateExec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LiveUpdateInitialSync) DeepCopyInto(out *LiveUpdateInitialSync) { + *out = *in + if in.IgnorePaths != nil { + in, out := &in.IgnorePaths, &out.IgnorePaths + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LiveUpdateInitialSync. +func (in *LiveUpdateInitialSync) DeepCopy() *LiveUpdateInitialSync { + if in == nil { + return nil + } + out := new(LiveUpdateInitialSync) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LiveUpdateKubernetesSelector) DeepCopyInto(out *LiveUpdateKubernetesSelector) { *out = *in @@ -2682,6 +2703,11 @@ func (in *LiveUpdateSpec) DeepCopyInto(out *LiveUpdateSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.InitialSync != nil { + in, out := &in.InitialSync, &out.InitialSync + *out = new(LiveUpdateInitialSync) + (*in).DeepCopyInto(*out) + } return } diff --git a/pkg/apis/core/v1alpha1/zz_generated.model_name.go b/pkg/apis/core/v1alpha1/zz_generated.model_name.go index 111c263fbb..601c32d5b7 100644 --- a/pkg/apis/core/v1alpha1/zz_generated.model_name.go +++ b/pkg/apis/core/v1alpha1/zz_generated.model_name.go @@ -526,6 +526,11 @@ func (in LiveUpdateExec) OpenAPIModelName() string { return "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1.LiveUpdateExec" } +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in LiveUpdateInitialSync) OpenAPIModelName() string { + return "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1.LiveUpdateInitialSync" +} + // OpenAPIModelName returns the OpenAPI model name for this type. func (in LiveUpdateKubernetesSelector) OpenAPIModelName() string { return "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1.LiveUpdateKubernetesSelector" diff --git a/pkg/openapi/zz_generated.openapi.go b/pkg/openapi/zz_generated.openapi.go index a8c13abada..93d4539d51 100644 --- a/pkg/openapi/zz_generated.openapi.go +++ b/pkg/openapi/zz_generated.openapi.go @@ -135,6 +135,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA v1alpha1.LiveUpdateContainerStatus{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateContainerStatus(ref), v1alpha1.LiveUpdateDockerComposeSelector{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateDockerComposeSelector(ref), v1alpha1.LiveUpdateExec{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateExec(ref), + v1alpha1.LiveUpdateInitialSync{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateInitialSync(ref), v1alpha1.LiveUpdateKubernetesSelector{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateKubernetesSelector(ref), v1alpha1.LiveUpdateList{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateList(ref), v1alpha1.LiveUpdateSelector{}.OpenAPIModelName(): schema_pkg_apis_core_v1alpha1_LiveUpdateSelector(ref), @@ -4906,6 +4907,41 @@ func schema_pkg_apis_core_v1alpha1_LiveUpdateExec(ref common.ReferenceCallback) } } +func schema_pkg_apis_core_v1alpha1_LiveUpdateInitialSync(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "LiveUpdateInitialSync configures initial sync behavior", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "ignorePaths": { + SchemaProps: spec.SchemaProps{ + Description: "IgnorePaths is a list of relative paths (relative to BasePath) to exclude from initial sync. These paths will still be synced on subsequent file changes.\n\nSupports exact matches and directory prefixes: - 'node_modules' excludes the node_modules directory - 'file.txt' excludes that specific file", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "dockerignore": { + SchemaProps: spec.SchemaProps{ + Description: "Dockerignore is a path to a directory containing a .dockerignore file to apply during initial sync. If empty, no .dockerignore is loaded.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_pkg_apis_core_v1alpha1_LiveUpdateKubernetesSelector(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -5145,12 +5181,18 @@ func schema_pkg_apis_core_v1alpha1_LiveUpdateSpec(ref common.ReferenceCallback) Format: "", }, }, + "initialSync": { + SchemaProps: spec.SchemaProps{ + Description: "InitialSync configures full file sync on container start/restart. When set, all files matching sync rules are uploaded when a container starts for the first time or restarts, bypassing the file-watch system.", + Ref: ref(v1alpha1.LiveUpdateInitialSync{}.OpenAPIModelName()), + }, + }, }, Required: []string{"basePath", "selector"}, }, }, Dependencies: []string{ - v1alpha1.LiveUpdateExec{}.OpenAPIModelName(), v1alpha1.LiveUpdateSelector{}.OpenAPIModelName(), v1alpha1.LiveUpdateSource{}.OpenAPIModelName(), v1alpha1.LiveUpdateSync{}.OpenAPIModelName()}, + v1alpha1.LiveUpdateExec{}.OpenAPIModelName(), v1alpha1.LiveUpdateInitialSync{}.OpenAPIModelName(), v1alpha1.LiveUpdateSelector{}.OpenAPIModelName(), v1alpha1.LiveUpdateSource{}.OpenAPIModelName(), v1alpha1.LiveUpdateSync{}.OpenAPIModelName()}, } } From 2846dfde8de6ff935f563e579c9a779ce8db0856 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Sat, 4 Apr 2026 20:07:37 +0300 Subject: [PATCH 02/14] tiltfile: add initial_sync() live update step Signed-off-by: arnas dundulis --- internal/tiltfile/live_update.go | 67 ++++++++++++ internal/tiltfile/live_update_test.go | 152 ++++++++++++++++++++++++++ internal/tiltfile/tiltfile_state.go | 2 + 3 files changed, 221 insertions(+) diff --git a/internal/tiltfile/live_update.go b/internal/tiltfile/live_update.go index 7a77cb2565..e402cc12c8 100644 --- a/internal/tiltfile/live_update.go +++ b/internal/tiltfile/live_update.go @@ -129,10 +129,60 @@ func (l liveUpdateRestartContainerStep) Hash() (uint32, error) { return 0, nil func (l liveUpdateRestartContainerStep) declarationPos() string { return l.position.String() } func (l liveUpdateRestartContainerStep) liveUpdateStep() {} +type liveUpdateInitialSyncStep struct { + ignorePaths []string + dockerignore string + position syntax.Position +} + +var _ starlark.Value = liveUpdateInitialSyncStep{} +var _ liveUpdateStep = liveUpdateInitialSyncStep{} + +func (l liveUpdateInitialSyncStep) String() string { + return fmt.Sprintf("initial_sync step (ignore: %v)", l.ignorePaths) +} +func (l liveUpdateInitialSyncStep) Type() string { return "live_update_initial_sync_step" } +func (l liveUpdateInitialSyncStep) Freeze() {} +func (l liveUpdateInitialSyncStep) Truth() starlark.Bool { return true } +func (l liveUpdateInitialSyncStep) Hash() (uint32, error) { return 0, nil } +func (l liveUpdateInitialSyncStep) declarationPos() string { return l.position.String() } +func (l liveUpdateInitialSyncStep) liveUpdateStep() {} + func (s *tiltfileState) recordLiveUpdateStep(step liveUpdateStep) { s.unconsumedLiveUpdateSteps[step.declarationPos()] = step } +// initialSync creates a live update step that syncs all files on container start/restart. +func (s *tiltfileState) liveUpdateInitialSync(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var ignoreVal starlark.Value + var dockerignore starlark.String + if err := s.unpackArgs(fn.Name(), args, kwargs, + "ignore?", &ignoreVal, + "dockerignore?", &dockerignore); err != nil { + return nil, err + } + + var ignorePaths []string + if ignoreVal != nil && ignoreVal != starlark.None { + ignoreList := starlarkValueOrSequenceToSlice(ignoreVal) + for _, item := range ignoreList { + if str, ok := item.(starlark.String); ok { + ignorePaths = append(ignorePaths, string(str)) + } else { + return nil, fmt.Errorf("initial_sync ignore paths must be strings, got %s", item.Type()) + } + } + } + + ret := liveUpdateInitialSyncStep{ + ignorePaths: ignorePaths, + dockerignore: string(dockerignore), + position: thread.CallFrame(1).Pos, + } + s.recordLiveUpdateStep(ret) + return ret, nil +} + func (s *tiltfileState) liveUpdateFallBackOn(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { files := value.NewLocalPathListUnpacker(thread) if err := s.unpackArgs(fn.Name(), args, kwargs, "paths", &files); err != nil { @@ -220,10 +270,12 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl } stepSlice := starlarkValueOrSequenceToSlice(maybeSteps) + if len(stepSlice) == 0 { return v1alpha1.LiveUpdateSpec{}, nil } + noMoreInitialSync := false noMoreFallbacks := false noMoreSyncs := false noMoreRuns := false @@ -235,7 +287,19 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl switch x := step.(type) { + case liveUpdateInitialSyncStep: + if noMoreInitialSync { + return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("initial_sync must appear at most once, at the start of the list") + } + noMoreInitialSync = true + + spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: x.ignorePaths, + Dockerignore: x.dockerignore, + } + case liveUpdateFallBackOnStep: + noMoreInitialSync = true if noMoreFallbacks { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("fall_back_on steps must appear at the start of the list") } @@ -257,6 +321,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl if noMoreSyncs { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("all sync steps must precede all run steps") } + noMoreInitialSync = true noMoreFallbacks = true localPath := x.localPath @@ -275,6 +340,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl if noMoreRuns { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("restart container is only valid as the last step") } + noMoreInitialSync = true noMoreFallbacks = true noMoreSyncs = true @@ -285,6 +351,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl }) case liveUpdateRestartContainerStep: + noMoreInitialSync = true noMoreFallbacks = true noMoreSyncs = true noMoreRuns = true diff --git a/internal/tiltfile/live_update_test.go b/internal/tiltfile/live_update_test.go index 10ce22fa31..db9d5ae9c4 100644 --- a/internal/tiltfile/live_update_test.go +++ b/internal/tiltfile/live_update_test.go @@ -499,3 +499,155 @@ func newLiveUpdateFixture(t *testing.T) *liveUpdateFixture { return f } + +func TestLiveUpdate_InitialSync_WithIgnore(t *testing.T) { + f := newFixture(t) + f.setupFoo() + + f.file("Tiltfile", ` +k8s_yaml('foo.yaml') +docker_build('gcr.io/foo', 'foo', + live_update=[ + initial_sync(ignore=['node_modules', '.git']), + sync('foo', '/app'), + run('npm install'), + ] +)`) + f.load() + + lu := v1alpha1.LiveUpdateSpec{ + BasePath: f.Path(), + Syncs: []v1alpha1.LiveUpdateSync{ + {LocalPath: "foo", ContainerPath: "/app"}, + }, + Execs: []v1alpha1.LiveUpdateExec{ + {Args: []string{"sh", "-c", "npm install"}}, + }, + InitialSync: &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"node_modules", ".git"}, + }, + } + + f.assertNextManifest("foo", db(image("gcr.io/foo"), lu)) +} + +func TestLiveUpdate_InitialSync_NoArgs(t *testing.T) { + f := newFixture(t) + f.setupFoo() + + f.file("Tiltfile", ` +k8s_yaml('foo.yaml') +docker_build('gcr.io/foo', 'foo', + live_update=[ + initial_sync(), + sync('foo', '/app'), + ] +)`) + f.load() + + lu := v1alpha1.LiveUpdateSpec{ + BasePath: f.Path(), + Syncs: []v1alpha1.LiveUpdateSync{ + {LocalPath: "foo", ContainerPath: "/app"}, + }, + InitialSync: &v1alpha1.LiveUpdateInitialSync{}, + } + + f.assertNextManifest("foo", db(image("gcr.io/foo"), lu)) +} + +func TestLiveUpdate_InitialSync_AbsolutePathError(t *testing.T) { + f := newFixture(t) + f.setupFoo() + + f.file("Tiltfile", ` +k8s_yaml('foo.yaml') +docker_build('gcr.io/foo', 'foo', + live_update=[ + initial_sync(ignore=['/absolute/path']), + sync('foo', '/app'), + ] +)`) + f.loadErrString("ignore paths must be relative to basePath") +} + +func TestLiveUpdate_InitialSync_InvalidIgnoreType(t *testing.T) { + f := newFixture(t) + f.setupFoo() + + f.file("Tiltfile", ` +k8s_yaml('foo.yaml') +docker_build('gcr.io/foo', 'foo', + live_update=[ + initial_sync(ignore=[123, 456]), + sync('foo', '/app'), + ] +)`) + f.loadErrString("initial_sync ignore paths must be strings") +} + +func TestLiveUpdate_InitialSync_MustBeFirst(t *testing.T) { + f := newFixture(t) + f.setupFoo() + + f.file("Tiltfile", ` +k8s_yaml('foo.yaml') +docker_build('gcr.io/foo', 'foo', + live_update=[ + sync('foo', '/app'), + initial_sync(), + ] +)`) + f.loadErrString("initial_sync must appear at most once, at the start of the list") +} + +func TestLiveUpdate_InitialSync_DuplicateError(t *testing.T) { + f := newFixture(t) + f.setupFoo() + + f.file("Tiltfile", ` +k8s_yaml('foo.yaml') +docker_build('gcr.io/foo', 'foo', + live_update=[ + initial_sync(), + initial_sync(), + sync('foo', '/app'), + ] +)`) + f.loadErrString("initial_sync must appear at most once, at the start of the list") +} + +func TestLiveUpdate_InitialSync_K8sCustomDeploy(t *testing.T) { + f := newFixture(t) + + f.file("Tiltfile", ` +k8s_custom_deploy('foo', 'apply', 'delete', deps=['foo'], container_selector='foo', + live_update=[ + initial_sync(ignore=['node_modules']), + sync('foo', '/app'), + run('npm install'), + ] +)`) + f.load() + + lu := v1alpha1.LiveUpdateSpec{ + BasePath: f.Path(), + Syncs: []v1alpha1.LiveUpdateSync{ + {LocalPath: "foo", ContainerPath: "/app"}, + }, + Execs: []v1alpha1.LiveUpdateExec{ + {Args: []string{"sh", "-c", "npm install"}}, + }, + InitialSync: &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"node_modules"}, + }, + Selector: v1alpha1.LiveUpdateSelector{ + Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ + ContainerName: "foo", + }, + }, + } + + m := f.assertNextManifest("foo", cb(image("k8s_custom_deploy:foo"), lu)) + assert.True(t, m.ImageTargets[0].IsLiveUpdateOnly) +} diff --git a/internal/tiltfile/tiltfile_state.go b/internal/tiltfile/tiltfile_state.go index d55c738850..509f531152 100644 --- a/internal/tiltfile/tiltfile_state.go +++ b/internal/tiltfile/tiltfile_state.go @@ -384,6 +384,7 @@ const ( helmN = "helm" // live update functions + initialSyncN = "initial_sync" fallBackOnN = "fall_back_on" syncN = "sync" runN = "run" @@ -564,6 +565,7 @@ func (s *tiltfileState) OnStart(e *starkit.Environment) error { {kustomizeN, s.kustomize}, {helmN, s.helm}, {triggerModeN, s.triggerModeFn}, + {initialSyncN, s.liveUpdateInitialSync}, {fallBackOnN, s.liveUpdateFallBackOn}, {syncN, s.liveUpdateSync}, {runN, s.liveUpdateRun}, From e382bfd3cb168f16dc7474e59400cf58138b5817 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Sat, 4 Apr 2026 20:07:37 +0300 Subject: [PATCH 03/14] reconciler: implement initial_sync file collection Signed-off-by: arnas dundulis --- .../controllers/core/liveupdate/reconciler.go | 102 ++++ .../core/liveupdate/reconciler_test.go | 516 ++++++++++++++++++ 2 files changed, 618 insertions(+) diff --git a/internal/controllers/core/liveupdate/reconciler.go b/internal/controllers/core/liveupdate/reconciler.go index d60ac5c651..3094ba9a15 100644 --- a/internal/controllers/core/liveupdate/reconciler.go +++ b/internal/controllers/core/liveupdate/reconciler.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "io/fs" + "os" + "path/filepath" "sync" "time" @@ -27,6 +30,7 @@ import ( "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" "github.com/tilt-dev/tilt/internal/controllers/apis/liveupdate" "github.com/tilt-dev/tilt/internal/controllers/indexer" + "github.com/tilt-dev/tilt/internal/dockerignore" "github.com/tilt-dev/tilt/internal/k8s" "github.com/tilt-dev/tilt/internal/ospath" "github.com/tilt-dev/tilt/internal/sliceutils" @@ -697,6 +701,24 @@ func (r *Reconciler) maybeSync(ctx context.Context, lu *v1alpha1.LiveUpdate, mon } } + // Initial sync: on new container, collect ALL files from sync paths + isInitialSync := lu.Spec.InitialSync != nil && (!ok || cStatus.lastFileTimeSynced.IsZero()) + if isInitialSync { + var err error + filesChanged, err = r.collectAllSyncedFiles(ctx, lu.Spec) + if err != nil { + status.Failed = createFailedState(lu, "InitialSyncError", + fmt.Sprintf("Failed to collect files for initial sync: %v", err)) + status.Containers = nil + return true + } + newHighWaterMark = apis.NowMicro() + // Set low water mark to reconciler start time so that any file changes + // between startup and initial sync completion are re-processed on the + // next reconcile, ensuring no changes are missed. + newLowWaterMark = r.startedTime + } + // Sort the files so that they're deterministic. filesChanged = sliceutils.DedupedAndSorted(filesChanged) if len(filesChanged) > 0 { @@ -1134,3 +1156,83 @@ func indexLiveUpdate(obj ctrlclient.Object) []indexer.Key { } return result } + +// collectAllSyncedFiles walks all sync paths and collects all files, +// applying .dockerignore patterns and InitialSync.IgnorePaths. +func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.LiveUpdateSpec) ([]string, error) { + var allFiles []string + + for _, syncSpec := range spec.Syncs { + localPath := syncSpec.LocalPath + if !filepath.IsAbs(localPath) { + localPath = filepath.Join(spec.BasePath, localPath) + } + + if _, err := os.Stat(localPath); os.IsNotExist(err) { + continue + } else if err != nil { + return nil, fmt.Errorf("stat %s: %w", localPath, err) + } + + ignoreMatcher, err := r.buildIgnoreMatcher(localPath, spec) + if err != nil { + return nil, fmt.Errorf("building ignore matcher for %s: %w", localPath, err) + } + + err = filepath.WalkDir(localPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if matches, _ := ignoreMatcher.MatchesEntireDir(path); matches { + return filepath.SkipDir + } + return nil + } + if matches, _ := ignoreMatcher.Matches(path); !matches { + allFiles = append(allFiles, path) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking sync path %s: %w", localPath, err) + } + } + + return allFiles, nil +} + +func (r *Reconciler) buildIgnoreMatcher(syncPath string, spec v1alpha1.LiveUpdateSpec) (model.PathMatcher, error) { + var matchers []model.PathMatcher + + // Load .dockerignore only if explicitly configured + if spec.InitialSync != nil && spec.InitialSync.Dockerignore != "" { + diPath := spec.InitialSync.Dockerignore + if !filepath.IsAbs(diPath) { + diPath = filepath.Join(spec.BasePath, diPath) + } + diMatcher, err := dockerignore.NewDockerIgnoreTester(diPath) + if err != nil { + return nil, fmt.Errorf("loading .dockerignore from %s: %w", diPath, err) + } + if diMatcher != nil { + matchers = append(matchers, diMatcher) + } + } + + if spec.InitialSync != nil && len(spec.InitialSync.IgnorePaths) > 0 { + pm, err := dockerignore.NewDockerPatternMatcher(syncPath, spec.InitialSync.IgnorePaths) + if err != nil { + return nil, fmt.Errorf("parsing ignore paths: %w", err) + } + matchers = append(matchers, pm) + } + + if len(matchers) == 0 { + return model.EmptyMatcher, nil + } + if len(matchers) == 1 { + return matchers[0], nil + } + return model.NewCompositeMatcher(matchers), nil +} diff --git a/internal/controllers/core/liveupdate/reconciler_test.go b/internal/controllers/core/liveupdate/reconciler_test.go index d914a6d484..4157d59f17 100644 --- a/internal/controllers/core/liveupdate/reconciler_test.go +++ b/internal/controllers/core/liveupdate/reconciler_test.go @@ -1142,3 +1142,519 @@ func (f *fixture) kdUpdateStatus(name string, status v1alpha1.KubernetesDiscover update.Status = status f.UpdateStatus(update) } + +func TestInitialSync_FirstContainerStart(t *testing.T) { + f := newFixture(t) + + // Create temp directory with test files + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file2.txt"), []byte("content2"), 0644)) + + // Create LiveUpdate with InitialSync enabled + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + } + luUpdate.Spec.Execs = []v1alpha1.LiveUpdateExec{ + {Args: []string{"sh", "-c", "npm install"}}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{}, + } + f.Update(luUpdate) + + // Add pod with running container + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + // Verify initial sync happened + require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call for initial sync") +} + +func TestInitialSync_NotRepeatedForSameContainer(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + // First container start — initial sync fires + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + require.Len(t, f.cu.Calls, 1, "Expected initial sync on first start") + + // Subsequent reconcile to same container doesn't re-sync + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + require.Len(t, f.cu.Calls, 1, "Should not re-sync without file changes") +} + +func TestInitialSync_FiresAgainOnRestart(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + // First container + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + require.Len(t, f.cu.Calls, 1, "Expected initial sync on first start") + + // Container restarts (new ID) — initial sync fires again + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-2", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + // Initial sync fires again for the new container + require.Len(t, f.cu.Calls, 2, "Initial sync should fire again for new container") +} + +func TestInitialSync_IgnorePaths(t *testing.T) { + f := newFixture(t) + + // Create temp directory with test files including ignored paths + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + nodeModulesDir := filepath.Join(srcDir, "node_modules") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.MkdirAll(nodeModulesDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(nodeModulesDir, "ignored.js"), []byte("ignored"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"node_modules"}, + } + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + // Verify initial sync happened (node_modules should be filtered out internally) + require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") +} + +func TestInitialSync_ExecsRespectTriggers(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + } + luUpdate.Spec.Execs = []v1alpha1.LiveUpdateExec{ + // This trigger won't match — no package.json was synced + {Args: []string{"sh", "-c", "npm install"}, TriggerPaths: []string{"package.json"}}, + // This trigger matches — src/file1.txt is under src/ + {Args: []string{"sh", "-c", "npm run build"}, TriggerPaths: []string{"src"}}, + // No trigger — always runs + {Args: []string{"sh", "-c", "echo done"}}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1) + call := f.cu.Calls[0] + // npm install skipped (trigger package.json not in synced files) + // npm run build runs (trigger src/ matches synced files) + // echo done runs (no trigger, always runs) + require.Len(t, call.Cmds, 2, "Only matching triggered execs and untriggered execs should run") + assert.Equal(t, []string{"sh", "-c", "npm run build"}, call.Cmds[0].Argv) + assert.Equal(t, []string{"sh", "-c", "echo done"}, call.Cmds[1].Argv) +} + +func TestInitialSync_NoInitialSyncWithoutConfig(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "file1.txt"), []byte("content1"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + } + // No InitialSync configured + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + // Without file changes and without initial_sync, no update should happen + assert.Len(t, f.cu.Calls, 0, "Should not sync without file changes when initial_sync is not configured") +} + +func TestInitialSync_ExplicitDockerignore(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "build"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "app.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "build", "output.bin"), []byte("binary"), 0644)) + // .dockerignore at sync root excludes build/ + require.NoError(t, os.WriteFile(filepath.Join(srcDir, ".dockerignore"), []byte("build/\n"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + Dockerignore: "src", + } + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") + // build/output.bin should be excluded by .dockerignore, so only app.go and .dockerignore synced +} + +func TestInitialSync_GlobIgnorePatterns(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "a", "spec"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "b", "spec"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "a.go"), []byte("package a"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "spec", "a_test.go"), []byte("test"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "b.go"), []byte("package b"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "spec", "b_test.go"), []byte("test"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"**/spec"}, + } + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") + // **/spec should exclude both pkg/a/spec and pkg/b/spec +} + +func TestInitialSync_MultipleSyncPaths(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + configDir := filepath.Join(tmpDir, "config") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.MkdirAll(configDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "app.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yml"), []byte("key: val"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + {LocalPath: "config", ContainerPath: "/app/config"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + // Files from both sync paths should be collected + require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") +} + +func TestInitialSync_NoSyncPaths(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = nil + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "frontend-image", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + // No sync paths means no files to collect, no update call + assert.Len(t, f.cu.Calls, 0, "Should not sync when there are no sync paths") +} From 2396bb3d7213b8548173f0d4dab5f9a8fd3be488 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 04:57:23 +0300 Subject: [PATCH 04/14] fix: enter maybeSync when initial_sync is configured Without this, maybeSync is gated behind hasChangesToSync which requires file change events or container state changes. On a fresh tilt up, the container starts with no file changes detected, so maybeSync is never entered and isInitialSync is never checked. This means initial_sync silently does nothing. Fix: when InitialSync is configured on the spec, always set hasChangesToSync=true so the reconciler enters maybeSync. The isInitialSync check inside maybeSync still correctly gates on whether the container is new (lastFileTimeSynced is zero). Signed-off-by: arnas dundulis --- internal/controllers/core/liveupdate/reconciler.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/controllers/core/liveupdate/reconciler.go b/internal/controllers/core/liveupdate/reconciler.go index 3094ba9a15..b3302bf392 100644 --- a/internal/controllers/core/liveupdate/reconciler.go +++ b/internal/controllers/core/liveupdate/reconciler.go @@ -185,6 +185,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu monitor.hasChangesToSync = true } + // When initial_sync is configured, always enter maybeSync so that new + // containers get their initial file sync even when no file changes have + // been detected yet. + if lu.Spec.InitialSync != nil { + monitor.hasChangesToSync = true + } + if monitor.hasChangesToSync { status := r.maybeSync(ctx, lu, monitor) if status.Failed != nil { From a1030eb8f897f9f0f9333798dcb87d672afafc70 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 04:57:27 +0300 Subject: [PATCH 05/14] fix: initial_sync tests match wrong container and miss auto-reconciles Two pre-existing test bugs: 1. Test containers use Image "frontend-image" which doesn't match the ImageMap selector (expects "local-registry:12345/frontend-image:my-tag"). Tests were actually syncing to the setupFrontend fixture's "main-id" container, not the test's "container-1". Assertions only checked call count so this was hidden. 2. f.Update() and f.kdUpdateStatus() auto-reconcile via MustReconcile, so initial sync fires during setup before the test's explicit MustReconcile call. Tests now clear f.cu.Calls before the assertion window and account for the correct reconcile sequence. Signed-off-by: arnas dundulis --- .../core/liveupdate/reconciler_test.go | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/internal/controllers/core/liveupdate/reconciler_test.go b/internal/controllers/core/liveupdate/reconciler_test.go index 4157d59f17..696d9399da 100644 --- a/internal/controllers/core/liveupdate/reconciler_test.go +++ b/internal/controllers/core/liveupdate/reconciler_test.go @@ -674,7 +674,7 @@ func TestKubernetesContainerNameSelector(t *testing.T) { { Name: "main", ID: "main-id", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1172,6 +1172,7 @@ func TestInitialSync_FirstContainerStart(t *testing.T) { f.Update(luUpdate) // Add pod with running container + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1182,7 +1183,7 @@ func TestInitialSync_FirstContainerStart(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1217,7 +1218,8 @@ func TestInitialSync_NotRepeatedForSameContainer(t *testing.T) { luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) - // First container start — initial sync fires + // First container start — initial sync fires via auto-reconcile + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1228,7 +1230,7 @@ func TestInitialSync_NotRepeatedForSameContainer(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1265,7 +1267,8 @@ func TestInitialSync_FiresAgainOnRestart(t *testing.T) { luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) - // First container + // First container — initial sync fires via auto-reconcile + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1276,7 +1279,7 @@ func TestInitialSync_FiresAgainOnRestart(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1289,6 +1292,7 @@ func TestInitialSync_FiresAgainOnRestart(t *testing.T) { require.Len(t, f.cu.Calls, 1, "Expected initial sync on first start") // Container restarts (new ID) — initial sync fires again + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1299,7 +1303,7 @@ func TestInitialSync_FiresAgainOnRestart(t *testing.T) { { Name: "main", ID: "container-2", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1310,7 +1314,7 @@ func TestInitialSync_FiresAgainOnRestart(t *testing.T) { }) f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) // Initial sync fires again for the new container - require.Len(t, f.cu.Calls, 2, "Initial sync should fire again for new container") + require.Len(t, f.cu.Calls, 1, "Initial sync should fire again for new container") } func TestInitialSync_IgnorePaths(t *testing.T) { @@ -1339,6 +1343,7 @@ func TestInitialSync_IgnorePaths(t *testing.T) { } f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1349,7 +1354,7 @@ func TestInitialSync_IgnorePaths(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1392,6 +1397,7 @@ func TestInitialSync_ExecsRespectTriggers(t *testing.T) { luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1402,7 +1408,7 @@ func TestInitialSync_ExecsRespectTriggers(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1443,6 +1449,7 @@ func TestInitialSync_NoInitialSyncWithoutConfig(t *testing.T) { // No InitialSync configured f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1453,7 +1460,7 @@ func TestInitialSync_NoInitialSyncWithoutConfig(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1493,6 +1500,7 @@ func TestInitialSync_ExplicitDockerignore(t *testing.T) { } f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1503,7 +1511,7 @@ func TestInitialSync_ExplicitDockerignore(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1545,6 +1553,7 @@ func TestInitialSync_GlobIgnorePatterns(t *testing.T) { } f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1555,7 +1564,7 @@ func TestInitialSync_GlobIgnorePatterns(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1594,6 +1603,7 @@ func TestInitialSync_MultipleSyncPaths(t *testing.T) { luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1604,7 +1614,7 @@ func TestInitialSync_MultipleSyncPaths(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1634,6 +1644,7 @@ func TestInitialSync_NoSyncPaths(t *testing.T) { luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) + f.cu.Calls = nil f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ Pods: []v1alpha1.Pod{ { @@ -1644,7 +1655,7 @@ func TestInitialSync_NoSyncPaths(t *testing.T) { { Name: "main", ID: "container-1", - Image: "frontend-image", + Image: "local-registry:12345/frontend-image:my-tag", State: v1alpha1.ContainerState{ Running: &v1alpha1.ContainerStateRunning{}, }, @@ -1658,3 +1669,5 @@ func TestInitialSync_NoSyncPaths(t *testing.T) { // No sync paths means no files to collect, no update call assert.Len(t, f.cu.Calls, 0, "Should not sync when there are no sync paths") } + +// tarEntryNames extracts file names from a tar archive captured by FakeContainerUpdater. From c05443159afc4e2c3e05df966f4e416febce5dd3 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 04:57:27 +0300 Subject: [PATCH 06/14] optimize: tar batching for initial_sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During initial sync, instead of collecting all individual file paths, converting them to PathMappings via FilesToPathMappings (O(files x syncs)), statting each for existence via MissingLocalPaths, and then creating a tar from individual file mappings — build the tar archive directly from directory-level sync mappings with the ignore filter passed to TarArchiveForPaths. This means a single directory walk produces the tar stream, skipping the redundant FilesToPathMappings conversion and MissingLocalPaths stat calls. collectAllSyncedFiles still runs for BoilRuns trigger matching. Benchmarks at 14k files: old (individual files): 189ms, 33MB allocs new (tar batched): 164ms, 21MB allocs (14% faster, 37% less memory) Also adds: - buildInitialSyncFilter: composes per-sync-path ignore matchers into a single filter for TarArchiveForPaths - Warnings when sync paths don't exist or dockerignore path has no .dockerignore file during initial sync Signed-off-by: arnas dundulis --- internal/controllers/core/liveupdate/input.go | 6 + .../controllers/core/liveupdate/reconciler.go | 118 +++++++++++++++--- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/internal/controllers/core/liveupdate/input.go b/internal/controllers/core/liveupdate/input.go index 1618ce5290..e94aacac5c 100644 --- a/internal/controllers/core/liveupdate/input.go +++ b/internal/controllers/core/liveupdate/input.go @@ -3,6 +3,7 @@ package liveupdate import ( "github.com/tilt-dev/tilt/internal/build" "github.com/tilt-dev/tilt/internal/store/liveupdates" + "github.com/tilt-dev/tilt/pkg/model" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -18,4 +19,9 @@ type Input struct { ChangedFiles []build.PathMapping LastFileTimeSynced metav1.MicroTime + + // InitialSyncFilter is set during initial sync to enable tar batching. + // When set, the archive is built directly from directory-level sync mappings + // with this filter applied, instead of archiving individual file PathMappings. + InitialSyncFilter model.PathMatcher } diff --git a/internal/controllers/core/liveupdate/reconciler.go b/internal/controllers/core/liveupdate/reconciler.go index b3302bf392..e701489310 100644 --- a/internal/controllers/core/liveupdate/reconciler.go +++ b/internal/controllers/core/liveupdate/reconciler.go @@ -708,7 +708,11 @@ func (r *Reconciler) maybeSync(ctx context.Context, lu *v1alpha1.LiveUpdate, mon } } - // Initial sync: on new container, collect ALL files from sync paths + // Initial sync: on new container, collect ALL files from sync paths. + // We collect the file list for trigger matching (BoilRuns), but the + // actual tar archive is built directly from directory-level sync + // mappings with the ignore filter, avoiding a second walk. + var initialSyncFilter model.PathMatcher isInitialSync := lu.Spec.InitialSync != nil && (!ok || cStatus.lastFileTimeSynced.IsZero()) if isInitialSync { var err error @@ -719,6 +723,15 @@ func (r *Reconciler) maybeSync(ctx context.Context, lu *v1alpha1.LiveUpdate, mon status.Containers = nil return true } + // Build the ignore filter for tar batching. We use the first sync + // path as the base for pattern matching — patterns are relative. + initialSyncFilter, err = r.buildInitialSyncFilter(lu.Spec) + if err != nil { + status.Failed = createFailedState(lu, "InitialSyncError", + fmt.Sprintf("Failed to build initial sync filter: %v", err)) + status.Containers = nil + return true + } newHighWaterMark = apis.NowMicro() // Set low water mark to reconciler start time so that any file changes // between startup and initial sync completion are re-processed on the @@ -807,6 +820,7 @@ func (r *Reconciler) maybeSync(ctx context.Context, lu *v1alpha1.LiveUpdate, mon ChangedFiles: plan.SyncPaths, Containers: []liveupdates.Container{c}, LastFileTimeSynced: newHighWaterMark, + InitialSyncFilter: initialSyncFilter, }) filesApplied = true } @@ -935,27 +949,46 @@ func (r *Reconciler) applyInternal( return result } - // rm files from container - toRemove, toArchive, err := build.MissingLocalPaths(ctx, changedFiles) - if err != nil { - result.Failed = &v1alpha1.LiveUpdateStateFailed{ - Reason: "Invalid", - Message: fmt.Sprintf("Mapping paths: %v", err), + // For initial sync, build a tar directly from sync directories with the + // ignore filter applied. This avoids the overhead of collecting all files + // into individual PathMappings and then re-walking them to build the tar. + // A single directory walk with filter is much faster for large trees (20k+ files). + var toRemove []build.PathMapping + var toArchive []build.PathMapping + var archiveFilter model.PathMatcher + if input.InitialSyncFilter != nil { + // No files to remove during initial sync — all files exist locally. + toRemove = nil + toArchive = build.SyncsToPathMappings(liveupdate.SyncSteps(spec)) + archiveFilter = input.InitialSyncFilter + l.Infof("Initial sync: will copy sync paths to container%s: %s", suffix, names) + for _, pm := range toArchive { + l.Infof("- %s", pm.PrettyStr()) + } + } else { + // rm files from container + var err2 error + toRemove, toArchive, err2 = build.MissingLocalPaths(ctx, changedFiles) + if err2 != nil { + result.Failed = &v1alpha1.LiveUpdateStateFailed{ + Reason: "Invalid", + Message: fmt.Sprintf("Mapping paths: %v", err2), + } + return result } - return result - } - if len(toRemove) > 0 { - l.Infof("Will delete %d file(s) from container%s: %s", len(toRemove), suffix, names) - for _, pm := range toRemove { - l.Infof("- '%s' (matched local path: '%s')", pm.ContainerPath, pm.LocalPath) + if len(toRemove) > 0 { + l.Infof("Will delete %d file(s) from container%s: %s", len(toRemove), suffix, names) + for _, pm := range toRemove { + l.Infof("- '%s' (matched local path: '%s')", pm.ContainerPath, pm.LocalPath) + } } - } - if len(toArchive) > 0 { - l.Infof("Will copy %d file(s) to container%s: %s", len(toArchive), suffix, names) - for _, pm := range toArchive { - l.Infof("- %s", pm.PrettyStr()) + if len(toArchive) > 0 { + l.Infof("Will copy %d file(s) to container%s: %s", len(toArchive), suffix, names) + for _, pm := range toArchive { + l.Infof("- %s", pm.PrettyStr()) + } } } @@ -964,7 +997,7 @@ func (r *Reconciler) applyInternal( // TODO(nick): We should try to distinguish between cases where the tar writer // fails (which is recoverable) vs when the server-side unpacking // fails (which may not be recoverable). - archive := build.TarArchiveForPaths(ctx, toArchive, nil) + archive := build.TarArchiveForPaths(ctx, toArchive, archiveFilter) err = cu.UpdateContainer(ctx, cInfo, archive, build.PathMappingsToContainerPaths(toRemove), boiledSteps, hotReload) _ = archive.Close() @@ -1167,8 +1200,21 @@ func indexLiveUpdate(obj ctrlclient.Object) []indexer.Key { // collectAllSyncedFiles walks all sync paths and collects all files, // applying .dockerignore patterns and InitialSync.IgnorePaths. func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.LiveUpdateSpec) ([]string, error) { + l := logger.Get(ctx) var allFiles []string + // Warn if the configured dockerignore directory has no .dockerignore file + if spec.InitialSync != nil && spec.InitialSync.Dockerignore != "" { + diPath := spec.InitialSync.Dockerignore + if !filepath.IsAbs(diPath) { + diPath = filepath.Join(spec.BasePath, diPath) + } + diFile := filepath.Join(diPath, ".dockerignore") + if _, err := os.Stat(diFile); os.IsNotExist(err) { + l.Warnf("initial_sync: configured dockerignore path %q has no .dockerignore file", diPath) + } + } + for _, syncSpec := range spec.Syncs { localPath := syncSpec.LocalPath if !filepath.IsAbs(localPath) { @@ -1176,6 +1222,7 @@ func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.Li } if _, err := os.Stat(localPath); os.IsNotExist(err) { + l.Warnf("initial_sync: sync path %q does not exist, skipping", localPath) continue } else if err != nil { return nil, fmt.Errorf("stat %s: %w", localPath, err) @@ -1209,6 +1256,39 @@ func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.Li return allFiles, nil } +// buildInitialSyncFilter creates a single PathMatcher that can be used as a +// tar archive filter during initial sync. Unlike buildIgnoreMatcher (which is +// per-sync-path), this builds a composite matcher that works across all sync +// paths by combining per-path matchers. +func (r *Reconciler) buildInitialSyncFilter(spec v1alpha1.LiveUpdateSpec) (model.PathMatcher, error) { + if spec.InitialSync == nil { + return model.EmptyMatcher, nil + } + + var matchers []model.PathMatcher + for _, syncSpec := range spec.Syncs { + localPath := syncSpec.LocalPath + if !filepath.IsAbs(localPath) { + localPath = filepath.Join(spec.BasePath, localPath) + } + m, err := r.buildIgnoreMatcher(localPath, spec) + if err != nil { + return nil, err + } + if m != model.EmptyMatcher { + matchers = append(matchers, m) + } + } + + if len(matchers) == 0 { + return model.EmptyMatcher, nil + } + if len(matchers) == 1 { + return matchers[0], nil + } + return model.NewCompositeMatcher(matchers), nil +} + func (r *Reconciler) buildIgnoreMatcher(syncPath string, spec v1alpha1.LiveUpdateSpec) (model.PathMatcher, error) { var matchers []model.PathMatcher From 13805898bdf877be321dae09c056697994bfb9f9 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 04:57:28 +0300 Subject: [PATCH 07/14] test: verify tar archive contents during initial_sync Add 6 tests that inspect the actual tar archive produced by initial_sync: - TarBatching_IgnoresFilteredFiles: IgnorePaths exclude files from tar - TarBatching_DockerignoreExcludesFromTar: .dockerignore patterns applied - TarBatching_MultipleSyncPathsInSingleTar: files from all syncs in one tar - TarBatching_NoDeletesDuringInitialSync: ToDelete is always empty - TarBatching_GlobPatternExcludesFromTar: **/testdata glob works - TarBatching_LargeFileTree: 1000 files with 100 ignored, correct count Signed-off-by: arnas dundulis --- .../core/liveupdate/reconciler_test.go | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) diff --git a/internal/controllers/core/liveupdate/reconciler_test.go b/internal/controllers/core/liveupdate/reconciler_test.go index 696d9399da..ac7fd55238 100644 --- a/internal/controllers/core/liveupdate/reconciler_test.go +++ b/internal/controllers/core/liveupdate/reconciler_test.go @@ -1,6 +1,7 @@ package liveupdate import ( + "archive/tar" "context" "errors" "fmt" @@ -1671,3 +1672,383 @@ func TestInitialSync_NoSyncPaths(t *testing.T) { } // tarEntryNames extracts file names from a tar archive captured by FakeContainerUpdater. +func tarEntryNames(t *testing.T, call containerupdate.UpdateContainerCall) []string { + t.Helper() + var names []string + tr := tar.NewReader(call.Archive) + for { + hdr, err := tr.Next() + if err != nil { + break + } + if hdr.Typeflag == tar.TypeReg { + names = append(names, hdr.Name) + } + } + return names +} + +func TestInitialSync_TarBatching_IgnoresFilteredFiles(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "node_modules", "pkg"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "vendor"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "app.js"), []byte("app"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "index.html"), []byte("html"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "node_modules", "pkg", "lib.js"), []byte("lib"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "vendor", "dep.js"), []byte("dep"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"node_modules", "vendor"}, + } + f.Update(luUpdate) + + f.cu.Calls = nil + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "local-registry:12345/frontend-image:my-tag", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + + // Verify the tar contains only the non-ignored files + assert.Contains(t, names, "app/app.js") + assert.Contains(t, names, "app/index.html") + + // Verify ignored directories are excluded from the tar + for _, name := range names { + assert.NotContains(t, name, "node_modules", "node_modules should be excluded from tar") + assert.NotContains(t, name, "vendor", "vendor should be excluded from tar") + } +} + +func TestInitialSync_TarBatching_DockerignoreExcludesFromTar(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "build"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "tmp"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "build", "output.bin"), []byte("binary"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "tmp", "cache.dat"), []byte("cache"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, ".dockerignore"), []byte("build/\ntmp/\n"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + Dockerignore: "src", + } + f.Update(luUpdate) + + f.cu.Calls = nil + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "local-registry:12345/frontend-image:my-tag", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + + // main.go and .dockerignore should be in the tar + assert.Contains(t, names, "app/main.go") + assert.Contains(t, names, "app/.dockerignore") + + // build/ and tmp/ should be excluded by .dockerignore + for _, name := range names { + assert.NotContains(t, name, "build/", "build/ should be excluded by .dockerignore") + assert.NotContains(t, name, "tmp/", "tmp/ should be excluded by .dockerignore") + } +} + +func TestInitialSync_TarBatching_MultipleSyncPathsInSingleTar(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + configDir := filepath.Join(tmpDir, "config") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.MkdirAll(configDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "app.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "handler.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yml"), []byte("key: val"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app/src"}, + {LocalPath: "config", ContainerPath: "/app/config"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + f.cu.Calls = nil + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "local-registry:12345/frontend-image:my-tag", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + // Verify single UpdateContainer call with files from both sync paths + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + + assert.Contains(t, names, "app/src/app.go") + assert.Contains(t, names, "app/src/handler.go") + assert.Contains(t, names, "app/config/config.yml") + assert.Len(t, names, 3, "Expected exactly 3 files in the tar") +} + +func TestInitialSync_TarBatching_NoDeletesDuringInitialSync(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(srcDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "app.go"), []byte("package main"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + f.Update(luUpdate) + + f.cu.Calls = nil + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "local-registry:12345/frontend-image:my-tag", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1) + // Initial sync should never have files to delete + assert.Empty(t, f.cu.Calls[0].ToDelete, "Initial sync should not delete any files") +} + +func TestInitialSync_TarBatching_GlobPatternExcludesFromTar(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "a", "testdata"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "b", "testdata"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "a.go"), []byte("package a"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "testdata", "fixture.json"), []byte("{}"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "b.go"), []byte("package b"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "testdata", "fixture.json"), []byte("{}"), 0644)) + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"**/testdata"}, + } + f.Update(luUpdate) + + f.cu.Calls = nil + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "local-registry:12345/frontend-image:my-tag", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + + // Source files should be present + assert.Contains(t, names, "app/main.go") + assert.Contains(t, names, "app/pkg/a/a.go") + assert.Contains(t, names, "app/pkg/b/b.go") + + // testdata dirs should be excluded by **/testdata glob + for _, name := range names { + assert.NotContains(t, name, "testdata", "testdata should be excluded from tar") + } + assert.Len(t, names, 3, "Expected exactly 3 files (testdata excluded)") +} + +func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { + f := newFixture(t) + + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + + // Create a moderately large file tree: 10 dirs x 100 files = 1000 files + // plus 10 dirs x 10 ignored files = 100 ignored files + for i := 0; i < 10; i++ { + dir := filepath.Join(srcDir, fmt.Sprintf("pkg%d", i)) + require.NoError(t, os.MkdirAll(dir, 0755)) + for j := 0; j < 100; j++ { + require.NoError(t, os.WriteFile( + filepath.Join(dir, fmt.Sprintf("file%d.go", j)), + []byte(fmt.Sprintf("package pkg%d", i)), 0644)) + } + // Create ignored test files + testDir := filepath.Join(dir, "test") + require.NoError(t, os.MkdirAll(testDir, 0755)) + for j := 0; j < 10; j++ { + require.NoError(t, os.WriteFile( + filepath.Join(testDir, fmt.Sprintf("test%d.go", j)), + []byte("package test"), 0644)) + } + } + + f.setupFrontend() + + var lu v1alpha1.LiveUpdate + f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) + luUpdate := lu.DeepCopy() + luUpdate.Spec.BasePath = tmpDir + luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ + {LocalPath: "src", ContainerPath: "/app"}, + } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ + IgnorePaths: []string{"**/test"}, + } + f.Update(luUpdate) + + f.cu.Calls = nil + f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ + Pods: []v1alpha1.Pod{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-1", + Image: "local-registry:12345/frontend-image:my-tag", + State: v1alpha1.ContainerState{ + Running: &v1alpha1.ContainerStateRunning{}, + }, + }, + }, + }, + }, + }) + f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) + + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + + // Should have exactly 1000 files (10 dirs x 100 files), no test files + assert.Len(t, names, 1000, "Expected 1000 files (test dirs excluded)") + for _, name := range names { + assert.NotContains(t, name, "/test/", "test directories should be excluded") + } + assert.Empty(t, f.cu.Calls[0].ToDelete, "No deletes during initial sync") +} From af7c324752a76983a1975b1dbce5fc57b87d9b65 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 04:57:30 +0300 Subject: [PATCH 08/14] cleanup: rename noMoreInitialSync, update IgnorePaths docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename noMoreInitialSync → seenInitialSync in liveUpdateFromSteps for clarity (the variable tracks whether we've seen an initial_sync step, not whether more are allowed) - Update IgnorePaths field comment to accurately describe dockerignore glob syntax support (**/ patterns, *.ext wildcards) instead of just "exact matches and directory prefixes" Signed-off-by: arnas dundulis --- internal/tiltfile/live_update.go | 14 +++++++------- pkg/apis/core/v1alpha1/liveupdate_types.go | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/tiltfile/live_update.go b/internal/tiltfile/live_update.go index e402cc12c8..6cff04352a 100644 --- a/internal/tiltfile/live_update.go +++ b/internal/tiltfile/live_update.go @@ -275,7 +275,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl return v1alpha1.LiveUpdateSpec{}, nil } - noMoreInitialSync := false + seenInitialSync := false noMoreFallbacks := false noMoreSyncs := false noMoreRuns := false @@ -288,10 +288,10 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl switch x := step.(type) { case liveUpdateInitialSyncStep: - if noMoreInitialSync { + if seenInitialSync { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("initial_sync must appear at most once, at the start of the list") } - noMoreInitialSync = true + seenInitialSync = true spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ IgnorePaths: x.ignorePaths, @@ -299,7 +299,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl } case liveUpdateFallBackOnStep: - noMoreInitialSync = true + seenInitialSync = true if noMoreFallbacks { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("fall_back_on steps must appear at the start of the list") } @@ -321,7 +321,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl if noMoreSyncs { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("all sync steps must precede all run steps") } - noMoreInitialSync = true + seenInitialSync = true noMoreFallbacks = true localPath := x.localPath @@ -340,7 +340,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl if noMoreRuns { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("restart container is only valid as the last step") } - noMoreInitialSync = true + seenInitialSync = true noMoreFallbacks = true noMoreSyncs = true @@ -351,7 +351,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl }) case liveUpdateRestartContainerStep: - noMoreInitialSync = true + seenInitialSync = true noMoreFallbacks = true noMoreSyncs = true noMoreRuns = true diff --git a/pkg/apis/core/v1alpha1/liveupdate_types.go b/pkg/apis/core/v1alpha1/liveupdate_types.go index 133cb19bcf..0d6f723a3e 100644 --- a/pkg/apis/core/v1alpha1/liveupdate_types.go +++ b/pkg/apis/core/v1alpha1/liveupdate_types.go @@ -414,9 +414,11 @@ type LiveUpdateInitialSync struct { // to exclude from initial sync. These paths will still be synced // on subsequent file changes. // - // Supports exact matches and directory prefixes: + // Uses dockerignore-style glob syntax: // - 'node_modules' excludes the node_modules directory // - 'file.txt' excludes that specific file + // - '**/test' excludes all directories named 'test' at any depth + // - '*.log' excludes all .log files // // +optional IgnorePaths []string `json:"ignorePaths,omitempty" protobuf:"bytes,1,rep,name=ignorePaths"` From e1d6198d159fb1c8ce4134c9bcedfc70606ab1a6 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 05:02:10 +0300 Subject: [PATCH 09/14] generate openapi Signed-off-by: arnas dundulis --- pkg/openapi/zz_generated.openapi.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/openapi/zz_generated.openapi.go b/pkg/openapi/zz_generated.openapi.go index 93d4539d51..94927aa212 100644 --- a/pkg/openapi/zz_generated.openapi.go +++ b/pkg/openapi/zz_generated.openapi.go @@ -4916,7 +4916,7 @@ func schema_pkg_apis_core_v1alpha1_LiveUpdateInitialSync(ref common.ReferenceCal Properties: map[string]spec.Schema{ "ignorePaths": { SchemaProps: spec.SchemaProps{ - Description: "IgnorePaths is a list of relative paths (relative to BasePath) to exclude from initial sync. These paths will still be synced on subsequent file changes.\n\nSupports exact matches and directory prefixes: - 'node_modules' excludes the node_modules directory - 'file.txt' excludes that specific file", + Description: "IgnorePaths is a list of relative paths (relative to BasePath) to exclude from initial sync. These paths will still be synced on subsequent file changes.\n\nUses dockerignore-style glob syntax: - 'node_modules' excludes the node_modules directory - 'file.txt' excludes that specific file - '**/test' excludes all directories named 'test' at any depth - '*.log' excludes all .log files", Type: []string{"array"}, Items: &spec.SchemaOrArray{ Schema: &spec.Schema{ From 55631703a3b9c75b66daf079ee27c9ab095ad442 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 02:13:30 +0000 Subject: [PATCH 10/14] fix: only force maybeSync when containers still need initial sync The previous approach set hasChangesToSync=true on every reconcile when InitialSync was configured, causing unnecessary work entering and exiting maybeSync after all containers have already been synced. Now uses monitor.needsInitialSync() which returns true only when no containers are tracked yet (first reconcile) or when any tracked container has never been synced (lastFileTimeSynced is zero). Once all containers complete their initial sync, the normal hasChangesToSync gate applies. Signed-off-by: arnas dundulis --- internal/controllers/core/liveupdate/monitor.go | 16 ++++++++++++++++ .../controllers/core/liveupdate/reconciler.go | 11 +++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/internal/controllers/core/liveupdate/monitor.go b/internal/controllers/core/liveupdate/monitor.go index 178b828643..cbadbdf34a 100644 --- a/internal/controllers/core/liveupdate/monitor.go +++ b/internal/controllers/core/liveupdate/monitor.go @@ -44,6 +44,22 @@ type monitorContainerKey struct { namespace string } +// needsInitialSync reports whether any container may still need an initial +// sync. Returns true when no containers are tracked yet (first reconcile, +// or after garbage collection) or when any tracked container has never +// been synced. +func (m *monitor) needsInitialSync() bool { + if len(m.containers) == 0 { + return true + } + for _, c := range m.containers { + if c.lastFileTimeSynced.IsZero() { + return true + } + } + return false +} + type monitorContainerStatus struct { lastFileTimeSynced metav1.MicroTime diff --git a/internal/controllers/core/liveupdate/reconciler.go b/internal/controllers/core/liveupdate/reconciler.go index e701489310..eb5085575e 100644 --- a/internal/controllers/core/liveupdate/reconciler.go +++ b/internal/controllers/core/liveupdate/reconciler.go @@ -185,10 +185,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu monitor.hasChangesToSync = true } - // When initial_sync is configured, always enter maybeSync so that new - // containers get their initial file sync even when no file changes have - // been detected yet. - if lu.Spec.InitialSync != nil { + // When initial_sync is configured, enter maybeSync if there may be + // unsynced containers. This handles the case where tilt starts and + // finds an already-running container — no file changes or k8s changes + // are detected, but the container still needs its initial sync. + // Once all tracked containers have been synced, skip this to avoid + // unnecessary work on every reconcile. + if lu.Spec.InitialSync != nil && monitor.needsInitialSync() { monitor.hasChangesToSync = true } From 4feb193e8f822d3b4dd35e465f384d47f6e26d64 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 02:15:10 +0000 Subject: [PATCH 11/14] cleanup: remove unnecessary err2 variable in applyInternal The err2 rename was an artifact of the tar batching refactor. Since err is not used in the outer scope at that point, use err directly with var. Signed-off-by: arnas dundulis --- internal/controllers/core/liveupdate/reconciler.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/controllers/core/liveupdate/reconciler.go b/internal/controllers/core/liveupdate/reconciler.go index eb5085575e..2c94afa5e1 100644 --- a/internal/controllers/core/liveupdate/reconciler.go +++ b/internal/controllers/core/liveupdate/reconciler.go @@ -970,12 +970,12 @@ func (r *Reconciler) applyInternal( } } else { // rm files from container - var err2 error - toRemove, toArchive, err2 = build.MissingLocalPaths(ctx, changedFiles) - if err2 != nil { + var err error + toRemove, toArchive, err = build.MissingLocalPaths(ctx, changedFiles) + if err != nil { result.Failed = &v1alpha1.LiveUpdateStateFailed{ Reason: "Invalid", - Message: fmt.Sprintf("Mapping paths: %v", err2), + Message: fmt.Sprintf("Mapping paths: %v", err), } return result } From 047a52d0e3e475532956b09061a0d964bdf3bf76 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 02:45:17 +0000 Subject: [PATCH 12/14] fix: use path.IsAbs for ignore path validation (Windows compat) filepath.IsAbs("/absolute/path") returns false on Windows since it's not a Windows absolute path (no drive letter). Use path.IsAbs (POSIX semantics) instead so the validation rejects Unix-style absolute paths on all platforms. Fixes TestLiveUpdate_InitialSync_AbsolutePathError on Windows. Signed-off-by: arnas dundulis --- pkg/apis/core/v1alpha1/liveupdate_types.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/apis/core/v1alpha1/liveupdate_types.go b/pkg/apis/core/v1alpha1/liveupdate_types.go index 0d6f723a3e..fe34bef172 100644 --- a/pkg/apis/core/v1alpha1/liveupdate_types.go +++ b/pkg/apis/core/v1alpha1/liveupdate_types.go @@ -19,7 +19,6 @@ package v1alpha1 import ( "context" "path" - "path/filepath" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -220,10 +219,12 @@ func (in *LiveUpdate) Validate(ctx context.Context) field.ErrorList { if in.Spec.InitialSync != nil { initialSyncPath := field.NewPath("spec", "initialSync") - // Validate ignore paths are relative (not absolute) + // Validate ignore paths are relative (not absolute). + // Use path.IsAbs (POSIX) rather than filepath.IsAbs so that + // "/absolute/path" is rejected on all platforms including Windows. ignorePathsField := initialSyncPath.Child("ignorePaths") for i, ignorePath := range in.Spec.InitialSync.IgnorePaths { - if filepath.IsAbs(ignorePath) { + if path.IsAbs(ignorePath) { errors = append(errors, field.Invalid(ignorePathsField.Index(i), ignorePath, "ignore paths must be relative to basePath")) @@ -231,7 +232,7 @@ func (in *LiveUpdate) Validate(ctx context.Context) field.ErrorList { } // Validate dockerignore path is relative - if in.Spec.InitialSync.Dockerignore != "" && filepath.IsAbs(in.Spec.InitialSync.Dockerignore) { + if in.Spec.InitialSync.Dockerignore != "" && path.IsAbs(in.Spec.InitialSync.Dockerignore) { errors = append(errors, field.Invalid(initialSyncPath.Child("dockerignore"), in.Spec.InitialSync.Dockerignore, "dockerignore path must be relative to basePath")) From 946a055989b7fb236297901914979726c2d5fdc5 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Fri, 10 Apr 2026 07:35:55 +0300 Subject: [PATCH 13/14] ci: retry Signed-off-by: arnas dundulis From df2adb5d732e4cc13fbb70102464efbd7bccf757 Mon Sep 17 00:00:00 2001 From: arnas dundulis Date: Thu, 16 Apr 2026 16:14:33 +0300 Subject: [PATCH 14/14] remove explicit ignore, reuse live_update ignore set Signed-off-by: arnas dundulis --- internal/controllers/core/liveupdate/input.go | 9 +- .../controllers/core/liveupdate/monitor.go | 1 + .../controllers/core/liveupdate/reconciler.go | 170 ++++-------- .../core/liveupdate/reconciler_test.go | 259 ++++-------------- internal/tiltfile/live_update.go | 33 +-- internal/tiltfile/live_update_test.go | 44 +-- pkg/apis/core/v1alpha1/liveupdate_types.go | 47 +--- .../core/v1alpha1/zz_generated.deepcopy.go | 7 +- pkg/openapi/zz_generated.openapi.go | 26 +- 9 files changed, 123 insertions(+), 473 deletions(-) diff --git a/internal/controllers/core/liveupdate/input.go b/internal/controllers/core/liveupdate/input.go index e94aacac5c..b5c9b59d8b 100644 --- a/internal/controllers/core/liveupdate/input.go +++ b/internal/controllers/core/liveupdate/input.go @@ -20,8 +20,11 @@ type Input struct { LastFileTimeSynced metav1.MicroTime - // InitialSyncFilter is set during initial sync to enable tar batching. - // When set, the archive is built directly from directory-level sync mappings - // with this filter applied, instead of archiving individual file PathMappings. + // InitialSync is set during initial sync to enable tar batching directly from + // the configured sync mappings instead of individual file PathMappings. + InitialSync bool + + // InitialSyncFilter applies the same ignore semantics used by the source + // FileWatch objects during the initial filesystem walk and tar creation. InitialSyncFilter model.PathMatcher } diff --git a/internal/controllers/core/liveupdate/monitor.go b/internal/controllers/core/liveupdate/monitor.go index cbadbdf34a..7190266a3d 100644 --- a/internal/controllers/core/liveupdate/monitor.go +++ b/internal/controllers/core/liveupdate/monitor.go @@ -34,6 +34,7 @@ type monitor struct { type monitorSource struct { modTimeByPath map[string]metav1.MicroTime + ignores []v1alpha1.IgnoreDef lastImageStatus *v1alpha1.ImageMapStatus lastFileEvent *v1alpha1.FileEvent } diff --git a/internal/controllers/core/liveupdate/reconciler.go b/internal/controllers/core/liveupdate/reconciler.go index 2c94afa5e1..611fccfcd8 100644 --- a/internal/controllers/core/liveupdate/reconciler.go +++ b/internal/controllers/core/liveupdate/reconciler.go @@ -30,7 +30,7 @@ import ( "github.com/tilt-dev/tilt/internal/controllers/apis/configmap" "github.com/tilt-dev/tilt/internal/controllers/apis/liveupdate" "github.com/tilt-dev/tilt/internal/controllers/indexer" - "github.com/tilt-dev/tilt/internal/dockerignore" + "github.com/tilt-dev/tilt/internal/ignore" "github.com/tilt-dev/tilt/internal/k8s" "github.com/tilt-dev/tilt/internal/ospath" "github.com/tilt-dev/tilt/internal/sliceutils" @@ -321,12 +321,25 @@ func (r *Reconciler) reconcileOneSource(ctx context.Context, monitor *monitor, s fwn := source.FileWatch imn := source.ImageMap + var mSource *monitorSource + if fwn != "" { + var ok bool + mSource, ok = monitor.sources[fwn] + if !ok { + mSource = &monitorSource{ + modTimeByPath: make(map[string]metav1.MicroTime), + } + monitor.sources[fwn] = mSource + } + } + var fw v1alpha1.FileWatch if fwn != "" { err := r.client.Get(ctx, types.NamespacedName{Name: fwn}, &fw) if err != nil { return false, err } + mSource.ignores = append([]v1alpha1.IgnoreDef(nil), fw.Spec.Ignores...) } var im v1alpha1.ImageMap @@ -342,14 +355,6 @@ func (r *Reconciler) reconcileOneSource(ctx context.Context, monitor *monitor, s return false, nil } - mSource, ok := monitor.sources[fwn] - if !ok { - mSource = &monitorSource{ - modTimeByPath: make(map[string]metav1.MicroTime), - } - monitor.sources[fwn] = mSource - } - newImageStatus := im.Status imageChanged := false if imn != "" { @@ -714,27 +719,19 @@ func (r *Reconciler) maybeSync(ctx context.Context, lu *v1alpha1.LiveUpdate, mon // Initial sync: on new container, collect ALL files from sync paths. // We collect the file list for trigger matching (BoilRuns), but the // actual tar archive is built directly from directory-level sync - // mappings with the ignore filter, avoiding a second walk. - var initialSyncFilter model.PathMatcher + // mappings, avoiding a second walk into individual file mappings. isInitialSync := lu.Spec.InitialSync != nil && (!ok || cStatus.lastFileTimeSynced.IsZero()) + initialSyncFilter := model.EmptyMatcher if isInitialSync { + initialSyncFilter = r.buildInitialSyncFilter(monitor) var err error - filesChanged, err = r.collectAllSyncedFiles(ctx, lu.Spec) + filesChanged, err = r.collectAllSyncedFiles(ctx, lu.Spec, initialSyncFilter) if err != nil { status.Failed = createFailedState(lu, "InitialSyncError", fmt.Sprintf("Failed to collect files for initial sync: %v", err)) status.Containers = nil return true } - // Build the ignore filter for tar batching. We use the first sync - // path as the base for pattern matching — patterns are relative. - initialSyncFilter, err = r.buildInitialSyncFilter(lu.Spec) - if err != nil { - status.Failed = createFailedState(lu, "InitialSyncError", - fmt.Sprintf("Failed to build initial sync filter: %v", err)) - status.Containers = nil - return true - } newHighWaterMark = apis.NowMicro() // Set low water mark to reconciler start time so that any file changes // between startup and initial sync completion are re-processed on the @@ -823,6 +820,7 @@ func (r *Reconciler) maybeSync(ctx context.Context, lu *v1alpha1.LiveUpdate, mon ChangedFiles: plan.SyncPaths, Containers: []liveupdates.Container{c}, LastFileTimeSynced: newHighWaterMark, + InitialSync: isInitialSync, InitialSyncFilter: initialSyncFilter, }) filesApplied = true @@ -952,14 +950,13 @@ func (r *Reconciler) applyInternal( return result } - // For initial sync, build a tar directly from sync directories with the - // ignore filter applied. This avoids the overhead of collecting all files - // into individual PathMappings and then re-walking them to build the tar. - // A single directory walk with filter is much faster for large trees (20k+ files). + // For initial sync, build a tar directly from sync directories. This avoids + // the overhead of collecting all files into individual PathMappings and then + // re-walking them to build the tar. var toRemove []build.PathMapping var toArchive []build.PathMapping - var archiveFilter model.PathMatcher - if input.InitialSyncFilter != nil { + archiveFilter := model.EmptyMatcher + if input.InitialSync { // No files to remove during initial sync — all files exist locally. toRemove = nil toArchive = build.SyncsToPathMappings(liveupdate.SyncSteps(spec)) @@ -1200,22 +1197,29 @@ func indexLiveUpdate(obj ctrlclient.Object) []indexer.Key { return result } -// collectAllSyncedFiles walks all sync paths and collects all files, -// applying .dockerignore patterns and InitialSync.IgnorePaths. -func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.LiveUpdateSpec) ([]string, error) { +func (r *Reconciler) buildInitialSyncFilter(monitor *monitor) model.PathMatcher { + if len(monitor.spec.Sources) == 0 { + return model.EmptyMatcher + } + + var allIgnores []v1alpha1.IgnoreDef + for _, source := range monitor.spec.Sources { + mSource, ok := monitor.sources[source.FileWatch] + if !ok { + continue + } + allIgnores = append(allIgnores, mSource.ignores...) + } + return ignore.CreateFileChangeFilter(allIgnores) +} + +// collectAllSyncedFiles walks all sync paths and collects all files. +func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.LiveUpdateSpec, filter model.PathMatcher) ([]string, error) { l := logger.Get(ctx) var allFiles []string - // Warn if the configured dockerignore directory has no .dockerignore file - if spec.InitialSync != nil && spec.InitialSync.Dockerignore != "" { - diPath := spec.InitialSync.Dockerignore - if !filepath.IsAbs(diPath) { - diPath = filepath.Join(spec.BasePath, diPath) - } - diFile := filepath.Join(diPath, ".dockerignore") - if _, err := os.Stat(diFile); os.IsNotExist(err) { - l.Warnf("initial_sync: configured dockerignore path %q has no .dockerignore file", diPath) - } + if filter == nil { + filter = model.EmptyMatcher } for _, syncSpec := range spec.Syncs { @@ -1231,24 +1235,24 @@ func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.Li return nil, fmt.Errorf("stat %s: %w", localPath, err) } - ignoreMatcher, err := r.buildIgnoreMatcher(localPath, spec) - if err != nil { - return nil, fmt.Errorf("building ignore matcher for %s: %w", localPath, err) - } - - err = filepath.WalkDir(localPath, func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(localPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { - if matches, _ := ignoreMatcher.MatchesEntireDir(path); matches { + if matches, err := filter.MatchesEntireDir(path); err != nil { + return err + } else if matches && path != localPath { return filepath.SkipDir } return nil } - if matches, _ := ignoreMatcher.Matches(path); !matches { - allFiles = append(allFiles, path) + if matches, err := filter.Matches(path); err != nil { + return err + } else if matches { + return nil } + allFiles = append(allFiles, path) return nil }) if err != nil { @@ -1258,71 +1262,3 @@ func (r *Reconciler) collectAllSyncedFiles(ctx context.Context, spec v1alpha1.Li return allFiles, nil } - -// buildInitialSyncFilter creates a single PathMatcher that can be used as a -// tar archive filter during initial sync. Unlike buildIgnoreMatcher (which is -// per-sync-path), this builds a composite matcher that works across all sync -// paths by combining per-path matchers. -func (r *Reconciler) buildInitialSyncFilter(spec v1alpha1.LiveUpdateSpec) (model.PathMatcher, error) { - if spec.InitialSync == nil { - return model.EmptyMatcher, nil - } - - var matchers []model.PathMatcher - for _, syncSpec := range spec.Syncs { - localPath := syncSpec.LocalPath - if !filepath.IsAbs(localPath) { - localPath = filepath.Join(spec.BasePath, localPath) - } - m, err := r.buildIgnoreMatcher(localPath, spec) - if err != nil { - return nil, err - } - if m != model.EmptyMatcher { - matchers = append(matchers, m) - } - } - - if len(matchers) == 0 { - return model.EmptyMatcher, nil - } - if len(matchers) == 1 { - return matchers[0], nil - } - return model.NewCompositeMatcher(matchers), nil -} - -func (r *Reconciler) buildIgnoreMatcher(syncPath string, spec v1alpha1.LiveUpdateSpec) (model.PathMatcher, error) { - var matchers []model.PathMatcher - - // Load .dockerignore only if explicitly configured - if spec.InitialSync != nil && spec.InitialSync.Dockerignore != "" { - diPath := spec.InitialSync.Dockerignore - if !filepath.IsAbs(diPath) { - diPath = filepath.Join(spec.BasePath, diPath) - } - diMatcher, err := dockerignore.NewDockerIgnoreTester(diPath) - if err != nil { - return nil, fmt.Errorf("loading .dockerignore from %s: %w", diPath, err) - } - if diMatcher != nil { - matchers = append(matchers, diMatcher) - } - } - - if spec.InitialSync != nil && len(spec.InitialSync.IgnorePaths) > 0 { - pm, err := dockerignore.NewDockerPatternMatcher(syncPath, spec.InitialSync.IgnorePaths) - if err != nil { - return nil, fmt.Errorf("parsing ignore paths: %w", err) - } - matchers = append(matchers, pm) - } - - if len(matchers) == 0 { - return model.EmptyMatcher, nil - } - if len(matchers) == 1 { - return matchers[0], nil - } - return model.NewCompositeMatcher(matchers), nil -} diff --git a/internal/controllers/core/liveupdate/reconciler_test.go b/internal/controllers/core/liveupdate/reconciler_test.go index ac7fd55238..6262c88666 100644 --- a/internal/controllers/core/liveupdate/reconciler_test.go +++ b/internal/controllers/core/liveupdate/reconciler_test.go @@ -116,7 +116,8 @@ func TestConsumeFileEvents(t *testing.T) { // Verify initial setup. m, ok := f.r.monitors["frontend-liveupdate"] require.True(t, ok) - assert.Equal(t, map[string]*monitorSource{}, m.sources) + require.Contains(t, m.sources, "frontend-fw") + assert.Empty(t, m.sources["frontend-fw"].modTimeByPath) assert.Equal(t, "frontend-discovery", m.lastKubernetesDiscovery.Name) assert.Nil(t, f.st.lastStartedAction) @@ -163,7 +164,8 @@ func TestConsumeFileEventsDockerCompose(t *testing.T) { // Verify initial setup. m, ok := f.r.monitors["frontend-liveupdate"] require.True(t, ok) - assert.Equal(t, map[string]*monitorSource{}, m.sources) + require.Contains(t, m.sources, "frontend-fw") + assert.Empty(t, m.sources["frontend-fw"].modTimeByPath) assert.Equal(t, "frontend-service", m.lastDockerComposeService.Name) assert.Nil(t, f.st.lastStartedAction) @@ -1167,9 +1169,7 @@ func TestInitialSync_FirstContainerStart(t *testing.T) { luUpdate.Spec.Execs = []v1alpha1.LiveUpdateExec{ {Args: []string{"sh", "-c", "npm install"}}, } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{}, - } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) // Add pod with running container @@ -1318,10 +1318,9 @@ func TestInitialSync_FiresAgainOnRestart(t *testing.T) { require.Len(t, f.cu.Calls, 1, "Initial sync should fire again for new container") } -func TestInitialSync_IgnorePaths(t *testing.T) { +func TestInitialSync_SyncsAllFilesInSyncPath(t *testing.T) { f := newFixture(t) - // Create temp directory with test files including ignored paths tmpDir := t.TempDir() srcDir := filepath.Join(tmpDir, "src") nodeModulesDir := filepath.Join(srcDir, "node_modules") @@ -1339,9 +1338,7 @@ func TestInitialSync_IgnorePaths(t *testing.T) { luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ {LocalPath: "src", ContainerPath: "/app/src"}, } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"node_modules"}, - } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) f.cu.Calls = nil @@ -1366,8 +1363,10 @@ func TestInitialSync_IgnorePaths(t *testing.T) { }) f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) - // Verify initial sync happened (node_modules should be filtered out internally) - require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + assert.Contains(t, names, "app/src/file1.txt") + assert.Contains(t, names, "app/src/node_modules/ignored.js") } func TestInitialSync_ExecsRespectTriggers(t *testing.T) { @@ -1476,7 +1475,7 @@ func TestInitialSync_NoInitialSyncWithoutConfig(t *testing.T) { assert.Len(t, f.cu.Calls, 0, "Should not sync without file changes when initial_sync is not configured") } -func TestInitialSync_ExplicitDockerignore(t *testing.T) { +func TestInitialSync_UsesSourceFileWatchIgnores(t *testing.T) { f := newFixture(t) tmpDir := t.TempDir() @@ -1484,63 +1483,18 @@ func TestInitialSync_ExplicitDockerignore(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "build"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(srcDir, "app.go"), []byte("package main"), 0644)) require.NoError(t, os.WriteFile(filepath.Join(srcDir, "build", "output.bin"), []byte("binary"), 0644)) - // .dockerignore at sync root excludes build/ require.NoError(t, os.WriteFile(filepath.Join(srcDir, ".dockerignore"), []byte("build/\n"), 0644)) f.setupFrontend() - var lu v1alpha1.LiveUpdate - f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) - luUpdate := lu.DeepCopy() - luUpdate.Spec.BasePath = tmpDir - luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ - {LocalPath: "src", ContainerPath: "/app"}, - } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - Dockerignore: "src", - } - f.Update(luUpdate) - - f.cu.Calls = nil - f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ - Pods: []v1alpha1.Pod{ - { - Name: "pod-1", - Namespace: "default", - Phase: "Running", - Containers: []v1alpha1.Container{ - { - Name: "main", - ID: "container-1", - Image: "local-registry:12345/frontend-image:my-tag", - State: v1alpha1.ContainerState{ - Running: &v1alpha1.ContainerStateRunning{}, - }, - }, - }, - }, - }, - }) - f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) - - require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") - // build/output.bin should be excluded by .dockerignore, so only app.go and .dockerignore synced -} - -func TestInitialSync_GlobIgnorePatterns(t *testing.T) { - f := newFixture(t) - - tmpDir := t.TempDir() - srcDir := filepath.Join(tmpDir, "src") - require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "a", "spec"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "b", "spec"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "a.go"), []byte("package a"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "spec", "a_test.go"), []byte("test"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "b.go"), []byte("package b"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "spec", "b_test.go"), []byte("test"), 0644)) - - f.setupFrontend() + var fw v1alpha1.FileWatch + f.MustGet(types.NamespacedName{Name: "frontend-fw"}, &fw) + fwUpdate := fw.DeepCopy() + fwUpdate.Spec.Ignores = []v1alpha1.IgnoreDef{{ + BasePath: srcDir, + Patterns: []string{"build/"}, + }} + f.Update(fwUpdate) var lu v1alpha1.LiveUpdate f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) @@ -1549,9 +1503,7 @@ func TestInitialSync_GlobIgnorePatterns(t *testing.T) { luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ {LocalPath: "src", ContainerPath: "/app"}, } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"**/spec"}, - } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) f.cu.Calls = nil @@ -1576,8 +1528,11 @@ func TestInitialSync_GlobIgnorePatterns(t *testing.T) { }) f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) - require.Len(t, f.cu.Calls, 1, "Expected one UpdateContainer call") - // **/spec should exclude both pkg/a/spec and pkg/b/spec + require.Len(t, f.cu.Calls, 1) + names := tarEntryNames(t, f.cu.Calls[0]) + assert.Contains(t, names, "app/app.go") + assert.Contains(t, names, "app/.dockerignore") + assert.NotContains(t, names, "app/build/output.bin") } func TestInitialSync_MultipleSyncPaths(t *testing.T) { @@ -1688,7 +1643,7 @@ func tarEntryNames(t *testing.T, call containerupdate.UpdateContainerCall) []str return names } -func TestInitialSync_TarBatching_IgnoresFilteredFiles(t *testing.T) { +func TestInitialSync_TarBatching_UsesFileWatchIgnores(t *testing.T) { f := newFixture(t) tmpDir := t.TempDir() @@ -1702,6 +1657,15 @@ func TestInitialSync_TarBatching_IgnoresFilteredFiles(t *testing.T) { f.setupFrontend() + var fw v1alpha1.FileWatch + f.MustGet(types.NamespacedName{Name: "frontend-fw"}, &fw) + fwUpdate := fw.DeepCopy() + fwUpdate.Spec.Ignores = []v1alpha1.IgnoreDef{{ + BasePath: srcDir, + Patterns: []string{"node_modules", "vendor"}, + }} + f.Update(fwUpdate) + var lu v1alpha1.LiveUpdate f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) luUpdate := lu.DeepCopy() @@ -1709,9 +1673,7 @@ func TestInitialSync_TarBatching_IgnoresFilteredFiles(t *testing.T) { luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ {LocalPath: "src", ContainerPath: "/app"}, } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"node_modules", "vendor"}, - } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) f.cu.Calls = nil @@ -1739,79 +1701,14 @@ func TestInitialSync_TarBatching_IgnoresFilteredFiles(t *testing.T) { require.Len(t, f.cu.Calls, 1) names := tarEntryNames(t, f.cu.Calls[0]) - // Verify the tar contains only the non-ignored files assert.Contains(t, names, "app/app.js") assert.Contains(t, names, "app/index.html") - - // Verify ignored directories are excluded from the tar for _, name := range names { assert.NotContains(t, name, "node_modules", "node_modules should be excluded from tar") assert.NotContains(t, name, "vendor", "vendor should be excluded from tar") } } -func TestInitialSync_TarBatching_DockerignoreExcludesFromTar(t *testing.T) { - f := newFixture(t) - - tmpDir := t.TempDir() - srcDir := filepath.Join(tmpDir, "src") - require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "build"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "tmp"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "build", "output.bin"), []byte("binary"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "tmp", "cache.dat"), []byte("cache"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, ".dockerignore"), []byte("build/\ntmp/\n"), 0644)) - - f.setupFrontend() - - var lu v1alpha1.LiveUpdate - f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) - luUpdate := lu.DeepCopy() - luUpdate.Spec.BasePath = tmpDir - luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ - {LocalPath: "src", ContainerPath: "/app"}, - } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - Dockerignore: "src", - } - f.Update(luUpdate) - - f.cu.Calls = nil - f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ - Pods: []v1alpha1.Pod{ - { - Name: "pod-1", - Namespace: "default", - Phase: "Running", - Containers: []v1alpha1.Container{ - { - Name: "main", - ID: "container-1", - Image: "local-registry:12345/frontend-image:my-tag", - State: v1alpha1.ContainerState{ - Running: &v1alpha1.ContainerStateRunning{}, - }, - }, - }, - }, - }, - }) - f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) - - require.Len(t, f.cu.Calls, 1) - names := tarEntryNames(t, f.cu.Calls[0]) - - // main.go and .dockerignore should be in the tar - assert.Contains(t, names, "app/main.go") - assert.Contains(t, names, "app/.dockerignore") - - // build/ and tmp/ should be excluded by .dockerignore - for _, name := range names { - assert.NotContains(t, name, "build/", "build/ should be excluded by .dockerignore") - assert.NotContains(t, name, "tmp/", "tmp/ should be excluded by .dockerignore") - } -} - func TestInitialSync_TarBatching_MultipleSyncPathsInSingleTar(t *testing.T) { f := newFixture(t) @@ -1916,70 +1813,6 @@ func TestInitialSync_TarBatching_NoDeletesDuringInitialSync(t *testing.T) { assert.Empty(t, f.cu.Calls[0].ToDelete, "Initial sync should not delete any files") } -func TestInitialSync_TarBatching_GlobPatternExcludesFromTar(t *testing.T) { - f := newFixture(t) - - tmpDir := t.TempDir() - srcDir := filepath.Join(tmpDir, "src") - require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "a", "testdata"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "pkg", "b", "testdata"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "main.go"), []byte("package main"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "a.go"), []byte("package a"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "a", "testdata", "fixture.json"), []byte("{}"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "b.go"), []byte("package b"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(srcDir, "pkg", "b", "testdata", "fixture.json"), []byte("{}"), 0644)) - - f.setupFrontend() - - var lu v1alpha1.LiveUpdate - f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) - luUpdate := lu.DeepCopy() - luUpdate.Spec.BasePath = tmpDir - luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ - {LocalPath: "src", ContainerPath: "/app"}, - } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"**/testdata"}, - } - f.Update(luUpdate) - - f.cu.Calls = nil - f.kdUpdateStatus("frontend-discovery", v1alpha1.KubernetesDiscoveryStatus{ - Pods: []v1alpha1.Pod{ - { - Name: "pod-1", - Namespace: "default", - Phase: "Running", - Containers: []v1alpha1.Container{ - { - Name: "main", - ID: "container-1", - Image: "local-registry:12345/frontend-image:my-tag", - State: v1alpha1.ContainerState{ - Running: &v1alpha1.ContainerStateRunning{}, - }, - }, - }, - }, - }, - }) - f.MustReconcile(types.NamespacedName{Name: "frontend-liveupdate"}) - - require.Len(t, f.cu.Calls, 1) - names := tarEntryNames(t, f.cu.Calls[0]) - - // Source files should be present - assert.Contains(t, names, "app/main.go") - assert.Contains(t, names, "app/pkg/a/a.go") - assert.Contains(t, names, "app/pkg/b/b.go") - - // testdata dirs should be excluded by **/testdata glob - for _, name := range names { - assert.NotContains(t, name, "testdata", "testdata should be excluded from tar") - } - assert.Len(t, names, 3, "Expected exactly 3 files (testdata excluded)") -} - func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { f := newFixture(t) @@ -1987,7 +1820,7 @@ func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { srcDir := filepath.Join(tmpDir, "src") // Create a moderately large file tree: 10 dirs x 100 files = 1000 files - // plus 10 dirs x 10 ignored files = 100 ignored files + // plus 10 dirs x 10 ignored files = 100 ignored files. for i := 0; i < 10; i++ { dir := filepath.Join(srcDir, fmt.Sprintf("pkg%d", i)) require.NoError(t, os.MkdirAll(dir, 0755)) @@ -1996,7 +1829,7 @@ func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { filepath.Join(dir, fmt.Sprintf("file%d.go", j)), []byte(fmt.Sprintf("package pkg%d", i)), 0644)) } - // Create ignored test files + // Create ignored test files. testDir := filepath.Join(dir, "test") require.NoError(t, os.MkdirAll(testDir, 0755)) for j := 0; j < 10; j++ { @@ -2008,6 +1841,15 @@ func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { f.setupFrontend() + var fw v1alpha1.FileWatch + f.MustGet(types.NamespacedName{Name: "frontend-fw"}, &fw) + fwUpdate := fw.DeepCopy() + fwUpdate.Spec.Ignores = []v1alpha1.IgnoreDef{{ + BasePath: srcDir, + Patterns: []string{"**/test"}, + }} + f.Update(fwUpdate) + var lu v1alpha1.LiveUpdate f.MustGet(types.NamespacedName{Name: "frontend-liveupdate"}, &lu) luUpdate := lu.DeepCopy() @@ -2015,9 +1857,7 @@ func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { luUpdate.Spec.Syncs = []v1alpha1.LiveUpdateSync{ {LocalPath: "src", ContainerPath: "/app"}, } - luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"**/test"}, - } + luUpdate.Spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} f.Update(luUpdate) f.cu.Calls = nil @@ -2045,7 +1885,6 @@ func TestInitialSync_TarBatching_LargeFileTree(t *testing.T) { require.Len(t, f.cu.Calls, 1) names := tarEntryNames(t, f.cu.Calls[0]) - // Should have exactly 1000 files (10 dirs x 100 files), no test files assert.Len(t, names, 1000, "Expected 1000 files (test dirs excluded)") for _, name := range names { assert.NotContains(t, name, "/test/", "test directories should be excluded") diff --git a/internal/tiltfile/live_update.go b/internal/tiltfile/live_update.go index 6cff04352a..ef6b6d9411 100644 --- a/internal/tiltfile/live_update.go +++ b/internal/tiltfile/live_update.go @@ -130,16 +130,14 @@ func (l liveUpdateRestartContainerStep) declarationPos() string { return l.posit func (l liveUpdateRestartContainerStep) liveUpdateStep() {} type liveUpdateInitialSyncStep struct { - ignorePaths []string - dockerignore string - position syntax.Position + position syntax.Position } var _ starlark.Value = liveUpdateInitialSyncStep{} var _ liveUpdateStep = liveUpdateInitialSyncStep{} func (l liveUpdateInitialSyncStep) String() string { - return fmt.Sprintf("initial_sync step (ignore: %v)", l.ignorePaths) + return "initial_sync step" } func (l liveUpdateInitialSyncStep) Type() string { return "live_update_initial_sync_step" } func (l liveUpdateInitialSyncStep) Freeze() {} @@ -154,30 +152,12 @@ func (s *tiltfileState) recordLiveUpdateStep(step liveUpdateStep) { // initialSync creates a live update step that syncs all files on container start/restart. func (s *tiltfileState) liveUpdateInitialSync(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var ignoreVal starlark.Value - var dockerignore starlark.String - if err := s.unpackArgs(fn.Name(), args, kwargs, - "ignore?", &ignoreVal, - "dockerignore?", &dockerignore); err != nil { + if err := s.unpackArgs(fn.Name(), args, kwargs); err != nil { return nil, err } - var ignorePaths []string - if ignoreVal != nil && ignoreVal != starlark.None { - ignoreList := starlarkValueOrSequenceToSlice(ignoreVal) - for _, item := range ignoreList { - if str, ok := item.(starlark.String); ok { - ignorePaths = append(ignorePaths, string(str)) - } else { - return nil, fmt.Errorf("initial_sync ignore paths must be strings, got %s", item.Type()) - } - } - } - ret := liveUpdateInitialSyncStep{ - ignorePaths: ignorePaths, - dockerignore: string(dockerignore), - position: thread.CallFrame(1).Pos, + position: thread.CallFrame(1).Pos, } s.recordLiveUpdateStep(ret) return ret, nil @@ -293,10 +273,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl } seenInitialSync = true - spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: x.ignorePaths, - Dockerignore: x.dockerignore, - } + spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} case liveUpdateFallBackOnStep: seenInitialSync = true diff --git a/internal/tiltfile/live_update_test.go b/internal/tiltfile/live_update_test.go index db9d5ae9c4..43f53a1f35 100644 --- a/internal/tiltfile/live_update_test.go +++ b/internal/tiltfile/live_update_test.go @@ -500,7 +500,7 @@ func newLiveUpdateFixture(t *testing.T) *liveUpdateFixture { return f } -func TestLiveUpdate_InitialSync_WithIgnore(t *testing.T) { +func TestLiveUpdate_InitialSync(t *testing.T) { f := newFixture(t) f.setupFoo() @@ -508,7 +508,7 @@ func TestLiveUpdate_InitialSync_WithIgnore(t *testing.T) { k8s_yaml('foo.yaml') docker_build('gcr.io/foo', 'foo', live_update=[ - initial_sync(ignore=['node_modules', '.git']), + initial_sync(), sync('foo', '/app'), run('npm install'), ] @@ -523,9 +523,7 @@ docker_build('gcr.io/foo', 'foo', Execs: []v1alpha1.LiveUpdateExec{ {Args: []string{"sh", "-c", "npm install"}}, }, - InitialSync: &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"node_modules", ".git"}, - }, + InitialSync: &v1alpha1.LiveUpdateInitialSync{}, } f.assertNextManifest("foo", db(image("gcr.io/foo"), lu)) @@ -556,36 +554,6 @@ docker_build('gcr.io/foo', 'foo', f.assertNextManifest("foo", db(image("gcr.io/foo"), lu)) } -func TestLiveUpdate_InitialSync_AbsolutePathError(t *testing.T) { - f := newFixture(t) - f.setupFoo() - - f.file("Tiltfile", ` -k8s_yaml('foo.yaml') -docker_build('gcr.io/foo', 'foo', - live_update=[ - initial_sync(ignore=['/absolute/path']), - sync('foo', '/app'), - ] -)`) - f.loadErrString("ignore paths must be relative to basePath") -} - -func TestLiveUpdate_InitialSync_InvalidIgnoreType(t *testing.T) { - f := newFixture(t) - f.setupFoo() - - f.file("Tiltfile", ` -k8s_yaml('foo.yaml') -docker_build('gcr.io/foo', 'foo', - live_update=[ - initial_sync(ignore=[123, 456]), - sync('foo', '/app'), - ] -)`) - f.loadErrString("initial_sync ignore paths must be strings") -} - func TestLiveUpdate_InitialSync_MustBeFirst(t *testing.T) { f := newFixture(t) f.setupFoo() @@ -623,7 +591,7 @@ func TestLiveUpdate_InitialSync_K8sCustomDeploy(t *testing.T) { f.file("Tiltfile", ` k8s_custom_deploy('foo', 'apply', 'delete', deps=['foo'], container_selector='foo', live_update=[ - initial_sync(ignore=['node_modules']), + initial_sync(), sync('foo', '/app'), run('npm install'), ] @@ -638,9 +606,7 @@ k8s_custom_deploy('foo', 'apply', 'delete', deps=['foo'], container_selector='fo Execs: []v1alpha1.LiveUpdateExec{ {Args: []string{"sh", "-c", "npm install"}}, }, - InitialSync: &v1alpha1.LiveUpdateInitialSync{ - IgnorePaths: []string{"node_modules"}, - }, + InitialSync: &v1alpha1.LiveUpdateInitialSync{}, Selector: v1alpha1.LiveUpdateSelector{ Kubernetes: &v1alpha1.LiveUpdateKubernetesSelector{ ContainerName: "foo", diff --git a/pkg/apis/core/v1alpha1/liveupdate_types.go b/pkg/apis/core/v1alpha1/liveupdate_types.go index fe34bef172..573e97e5ad 100644 --- a/pkg/apis/core/v1alpha1/liveupdate_types.go +++ b/pkg/apis/core/v1alpha1/liveupdate_types.go @@ -215,30 +215,6 @@ func (in *LiveUpdate) Validate(ctx context.Context) field.ErrorList { } } - // Validate InitialSync fields - if in.Spec.InitialSync != nil { - initialSyncPath := field.NewPath("spec", "initialSync") - - // Validate ignore paths are relative (not absolute). - // Use path.IsAbs (POSIX) rather than filepath.IsAbs so that - // "/absolute/path" is rejected on all platforms including Windows. - ignorePathsField := initialSyncPath.Child("ignorePaths") - for i, ignorePath := range in.Spec.InitialSync.IgnorePaths { - if path.IsAbs(ignorePath) { - errors = append(errors, - field.Invalid(ignorePathsField.Index(i), ignorePath, - "ignore paths must be relative to basePath")) - } - } - - // Validate dockerignore path is relative - if in.Spec.InitialSync.Dockerignore != "" && path.IsAbs(in.Spec.InitialSync.Dockerignore) { - errors = append(errors, - field.Invalid(initialSyncPath.Child("dockerignore"), in.Spec.InitialSync.Dockerignore, - "dockerignore path must be relative to basePath")) - } - } - return errors } @@ -409,27 +385,8 @@ var ( LiveUpdateRestartStrategyAlways LiveUpdateRestartStrategy = "always" ) -// LiveUpdateInitialSync configures initial sync behavior -type LiveUpdateInitialSync struct { - // IgnorePaths is a list of relative paths (relative to BasePath) - // to exclude from initial sync. These paths will still be synced - // on subsequent file changes. - // - // Uses dockerignore-style glob syntax: - // - 'node_modules' excludes the node_modules directory - // - 'file.txt' excludes that specific file - // - '**/test' excludes all directories named 'test' at any depth - // - '*.log' excludes all .log files - // - // +optional - IgnorePaths []string `json:"ignorePaths,omitempty" protobuf:"bytes,1,rep,name=ignorePaths"` - - // Dockerignore is a path to a directory containing a .dockerignore file - // to apply during initial sync. If empty, no .dockerignore is loaded. - // - // +optional - Dockerignore string `json:"dockerignore,omitempty" protobuf:"bytes,2,opt,name=dockerignore"` -} +// LiveUpdateInitialSync enables full file sync on container start/restart. +type LiveUpdateInitialSync struct{} // LiveUpdateContainerStatus defines the observed state of // the live-update syncer for a particular container. diff --git a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go index bf6a7f69f4..16b0d64da5 100644 --- a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -2568,11 +2568,6 @@ func (in *LiveUpdateExec) DeepCopy() *LiveUpdateExec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LiveUpdateInitialSync) DeepCopyInto(out *LiveUpdateInitialSync) { *out = *in - if in.IgnorePaths != nil { - in, out := &in.IgnorePaths, &out.IgnorePaths - *out = make([]string, len(*in)) - copy(*out, *in) - } return } @@ -2706,7 +2701,7 @@ func (in *LiveUpdateSpec) DeepCopyInto(out *LiveUpdateSpec) { if in.InitialSync != nil { in, out := &in.InitialSync, &out.InitialSync *out = new(LiveUpdateInitialSync) - (*in).DeepCopyInto(*out) + **out = **in } return } diff --git a/pkg/openapi/zz_generated.openapi.go b/pkg/openapi/zz_generated.openapi.go index 94927aa212..3d9621425e 100644 --- a/pkg/openapi/zz_generated.openapi.go +++ b/pkg/openapi/zz_generated.openapi.go @@ -4911,32 +4911,8 @@ func schema_pkg_apis_core_v1alpha1_LiveUpdateInitialSync(ref common.ReferenceCal return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ - Description: "LiveUpdateInitialSync configures initial sync behavior", + Description: "LiveUpdateInitialSync enables full file sync on container start/restart.", Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "ignorePaths": { - SchemaProps: spec.SchemaProps{ - Description: "IgnorePaths is a list of relative paths (relative to BasePath) to exclude from initial sync. These paths will still be synced on subsequent file changes.\n\nUses dockerignore-style glob syntax: - 'node_modules' excludes the node_modules directory - 'file.txt' excludes that specific file - '**/test' excludes all directories named 'test' at any depth - '*.log' excludes all .log files", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "dockerignore": { - SchemaProps: spec.SchemaProps{ - Description: "Dockerignore is a path to a directory containing a .dockerignore file to apply during initial sync. If empty, no .dockerignore is loaded.", - Type: []string{"string"}, - Format: "", - }, - }, - }, }, }, }