diff --git a/internal/controllers/core/liveupdate/input.go b/internal/controllers/core/liveupdate/input.go index 1618ce5290..b5c9b59d8b 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,12 @@ type Input struct { ChangedFiles []build.PathMapping LastFileTimeSynced metav1.MicroTime + + // 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 178b828643..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 } @@ -44,6 +45,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 d60ac5c651..611fccfcd8 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/ignore" "github.com/tilt-dev/tilt/internal/k8s" "github.com/tilt-dev/tilt/internal/ospath" "github.com/tilt-dev/tilt/internal/sliceutils" @@ -181,6 +185,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu monitor.hasChangesToSync = true } + // 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 + } + if monitor.hasChangesToSync { status := r.maybeSync(ctx, lu, monitor) if status.Failed != nil { @@ -307,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 @@ -328,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 != "" { @@ -697,6 +716,29 @@ 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, 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, 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 + } + 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 { @@ -778,6 +820,8 @@ 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 } @@ -906,27 +950,45 @@ 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. 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 + 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)) + 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 err error + toRemove, toArchive, err = build.MissingLocalPaths(ctx, changedFiles) + if err != nil { + result.Failed = &v1alpha1.LiveUpdateStateFailed{ + Reason: "Invalid", + Message: fmt.Sprintf("Mapping paths: %v", err), + } + 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()) + } } } @@ -935,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() @@ -1134,3 +1196,69 @@ func indexLiveUpdate(obj ctrlclient.Object) []indexer.Key { } return result } + +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 + + if filter == nil { + filter = model.EmptyMatcher + } + + 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) { + 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) + } + + err := filepath.WalkDir(localPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if matches, err := filter.MatchesEntireDir(path); err != nil { + return err + } else if matches && path != localPath { + return filepath.SkipDir + } + return nil + } + if matches, err := filter.Matches(path); err != nil { + return err + } else if matches { + return nil + } + allFiles = append(allFiles, path) + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking sync path %s: %w", localPath, err) + } + } + + return allFiles, nil +} diff --git a/internal/controllers/core/liveupdate/reconciler_test.go b/internal/controllers/core/liveupdate/reconciler_test.go index d914a6d484..6262c88666 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" @@ -115,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) @@ -162,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) @@ -674,7 +677,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{}, }, @@ -1142,3 +1145,749 @@ 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{} + f.Update(luUpdate) + + // Add pod with running container + 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 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 via auto-reconcile + 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 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 — initial sync fires via auto-reconcile + 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 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{ + { + Name: "pod-1", + Namespace: "default", + Phase: "Running", + Containers: []v1alpha1.Container{ + { + Name: "main", + ID: "container-2", + Image: "local-registry:12345/frontend-image:my-tag", + 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, 1, "Initial sync should fire again for new container") +} + +func TestInitialSync_SyncsAllFilesInSyncPath(t *testing.T) { + f := newFixture(t) + + 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{} + 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]) + assert.Contains(t, names, "app/src/file1.txt") + assert.Contains(t, names, "app/src/node_modules/ignored.js") +} + +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.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) + 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.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"}) + + // 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_UsesSourceFileWatchIgnores(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)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, ".dockerignore"), []byte("build/\n"), 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) + 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) + 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) { + 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.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"}) + + // 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.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"}) + + // 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. +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_UsesFileWatchIgnores(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 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() + 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) + names := tarEntryNames(t, f.cu.Calls[0]) + + assert.Contains(t, names, "app/app.js") + assert.Contains(t, names, "app/index.html") + 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_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_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 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() + 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) + names := tarEntryNames(t, f.cu.Calls[0]) + + 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") +} diff --git a/internal/tiltfile/live_update.go b/internal/tiltfile/live_update.go index 7a77cb2565..ef6b6d9411 100644 --- a/internal/tiltfile/live_update.go +++ b/internal/tiltfile/live_update.go @@ -129,10 +129,40 @@ 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 { + position syntax.Position +} + +var _ starlark.Value = liveUpdateInitialSyncStep{} +var _ liveUpdateStep = liveUpdateInitialSyncStep{} + +func (l liveUpdateInitialSyncStep) String() string { + return "initial_sync step" +} +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) { + if err := s.unpackArgs(fn.Name(), args, kwargs); err != nil { + return nil, err + } + + ret := liveUpdateInitialSyncStep{ + 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 +250,12 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl } stepSlice := starlarkValueOrSequenceToSlice(maybeSteps) + if len(stepSlice) == 0 { return v1alpha1.LiveUpdateSpec{}, nil } + seenInitialSync := false noMoreFallbacks := false noMoreSyncs := false noMoreRuns := false @@ -235,7 +267,16 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl switch x := step.(type) { + case liveUpdateInitialSyncStep: + if seenInitialSync { + return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("initial_sync must appear at most once, at the start of the list") + } + seenInitialSync = true + + spec.InitialSync = &v1alpha1.LiveUpdateInitialSync{} + case liveUpdateFallBackOnStep: + seenInitialSync = true if noMoreFallbacks { return v1alpha1.LiveUpdateSpec{}, fmt.Errorf("fall_back_on steps must appear at the start of the list") } @@ -257,6 +298,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") } + seenInitialSync = true noMoreFallbacks = true localPath := x.localPath @@ -275,6 +317,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") } + seenInitialSync = true noMoreFallbacks = true noMoreSyncs = true @@ -285,6 +328,7 @@ func (s *tiltfileState) liveUpdateFromSteps(t *starlark.Thread, maybeSteps starl }) case liveUpdateRestartContainerStep: + seenInitialSync = 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..43f53a1f35 100644 --- a/internal/tiltfile/live_update_test.go +++ b/internal/tiltfile/live_update_test.go @@ -499,3 +499,121 @@ func newLiveUpdateFixture(t *testing.T) *liveUpdateFixture { return f } + +func TestLiveUpdate_InitialSync(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'), + 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{}, + } + + 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_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(), + 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{}, + 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}, diff --git a/pkg/apis/core/v1alpha1/liveupdate_types.go b/pkg/apis/core/v1alpha1/liveupdate_types.go index cd2943c811..573e97e5ad 100644 --- a/pkg/apis/core/v1alpha1/liveupdate_types.go +++ b/pkg/apis/core/v1alpha1/liveupdate_types.go @@ -110,6 +110,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{} @@ -378,6 +385,9 @@ var ( LiveUpdateRestartStrategyAlways LiveUpdateRestartStrategy = "always" ) +// 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. 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..16b0d64da5 100644 --- a/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/core/v1alpha1/zz_generated.deepcopy.go @@ -2565,6 +2565,22 @@ 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 + 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 +2698,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) + **out = **in + } 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..3d9621425e 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,17 @@ 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 enables full file sync on container start/restart.", + Type: []string{"object"}, + }, + }, + } +} + func schema_pkg_apis_core_v1alpha1_LiveUpdateKubernetesSelector(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -5145,12 +5157,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()}, } }