diff --git a/README.md b/README.md index 4a32dd1cb475..2d268b09b3d0 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,15 @@ $ tree ./bin └── hello-linux-arm64 ``` +Local output also supports `mode=`: + +- `copy` (default) preserves existing files in destination that are not present in build result. +- `delete` removes destination files and directories that are not present in build result. + +```bash +buildctl build ... --output type=local,dest=./bin/release,mode=delete +``` + Tar exporter is similar to local exporter but transfers the files through a tarball. ```bash diff --git a/client/client_test.go b/client/client_test.go index 7963a6a39f45..2b27d1e082dc 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -238,6 +238,11 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testExportLocalNoPlatformSplit, testExportLocalNoPlatformSplitOverwrite, testExportLocalForcePlatformSplit, + testExportLocalModeCopyKeepsStaleDestinationFiles, + testExportLocalModeDeleteRemovesStaleDestinationFiles, + testExportLocalModeCopyMultiPlatformKeepsAllPlatforms, + testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms, + testExportLocalModeInvalid, testSolverOptLocalDirsStillWorks, testOCIIndexMediatype, testLayerLimitOnMounts, @@ -7622,6 +7627,240 @@ func testExportLocalForcePlatformSplit(t *testing.T, sb integration.Sandbox) { require.Equal(t, "hello", string(dt)) } +func testExportLocalModeCopyKeepsStaleDestinationFiles(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Mkfile("fresh.txt", 0600, []byte("fresh")), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) + require.NoError(t, err) + err = os.MkdirAll(filepath.Join(destDir, "stale-dir"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(destDir, "stale-dir", "old.txt"), []byte("stale"), 0600) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "fresh.txt")) + require.NoError(t, err) + require.Equal(t, "fresh", string(dt)) + + _, err = os.Stat(filepath.Join(destDir, "stale.txt")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "stale-dir", "old.txt")) + require.NoError(t, err) +} + +func testExportLocalModeDeleteRemovesStaleDestinationFiles(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Mkfile("fresh.txt", 0600, []byte("fresh")), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) + require.NoError(t, err) + err = os.MkdirAll(filepath.Join(destDir, "stale-dir"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(destDir, "stale-dir", "old.txt"), []byte("stale"), 0600) + require.NoError(t, err) + + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{ + "mode": "delete", + }, + }, + }, + }, nil) + require.NoError(t, err) + + dt, err := os.ReadFile(filepath.Join(destDir, "fresh.txt")) + require.NoError(t, err) + require.Equal(t, "fresh", string(dt)) + + _, err = os.Stat(filepath.Join(destDir, "stale.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(destDir, "stale-dir")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func testExportLocalModeCopyMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox) { + testExportLocalModeMultiPlatformKeepsAllPlatforms(t, sb, false) +} + +func testExportLocalModeDeleteMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox) { + testExportLocalModeMultiPlatformKeepsAllPlatforms(t, sb, true) +} + +func testExportLocalModeMultiPlatformKeepsAllPlatforms(t *testing.T, sb integration.Sandbox, deleteMode bool) { + workers.CheckFeatureCompat(t, sb, workers.FeatureOCIExporter, workers.FeatureMultiPlatform) + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + const filesPerPlatform = 20 + platformsToTest := []string{"linux/amd64", "linux/arm64", "linux/arm/v7", "linux/s390x"} + + frontend := func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + res := gateway.NewResult() + expPlatforms := &exptypes.Platforms{ + Platforms: make([]exptypes.Platform, len(platformsToTest)), + } + for i, platform := range platformsToTest { + st := llb.Scratch() + for j := range filesPerPlatform { + st = st.File( + llb.Mkfile(fmt.Sprintf("file-%03d.txt", j), 0600, fmt.Appendf(nil, "%s-%d", platform, j)), + ) + } + + def, err := st.Marshal(ctx) + if err != nil { + return nil, err + } + + r, err := c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + }) + if err != nil { + return nil, err + } + + ref, err := r.SingleRef() + if err != nil { + return nil, err + } + + _, err = ref.ToState() + if err != nil { + return nil, err + } + res.AddRef(platform, ref) + + expPlatforms.Platforms[i] = exptypes.Platform{ + ID: platform, + Platform: platforms.MustParse(platform), + } + } + dt, err := json.Marshal(expPlatforms) + if err != nil { + return nil, err + } + res.AddMeta(exptypes.ExporterPlatformsKey, dt) + + return res, nil + } + + destDir := t.TempDir() + + // Pre-populate dest with directories matching the platform-split naming + // convention. This is critical for exposing the race in delete mode: + // each platform's fsutil.Receive does a dest-walk at the start and, with + // Merge=false (mode=delete), flags everything not in its own stream for + // deletion — including other platforms' pre-existing directories. Without + // pre-population the dest starts empty and the concurrent dest-walks all + // see nothing to delete, hiding the cross-platform deletion race. + err = os.WriteFile(filepath.Join(destDir, "stale.txt"), []byte("stale"), 0600) + require.NoError(t, err) + for _, platform := range platformsToTest { + platDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) + err = os.MkdirAll(platDir, 0755) + require.NoError(t, err) + for j := range filesPerPlatform { + err = os.WriteFile(filepath.Join(platDir, fmt.Sprintf("old-%03d.txt", j)), []byte("old"), 0600) + require.NoError(t, err) + } + } + + attrs := map[string]string{} + if deleteMode { + attrs["mode"] = "delete" + } + + _, err = c.Build(sb.Context(), SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: attrs, + }, + }, + }, "", frontend, nil) + require.NoError(t, err) + + if deleteMode { + // Stale top-level file must be gone. + _, err = os.Stat(filepath.Join(destDir, "stale.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + } + + // Every platform's build output must survive (no cross-platform deletion). + for _, platform := range platformsToTest { + platformDir := filepath.Join(destDir, strings.ReplaceAll(platform, "/", "_")) + for j := range filesPerPlatform { + dt, err := os.ReadFile(filepath.Join(platformDir, fmt.Sprintf("file-%03d.txt", j))) + require.NoError(t, err, "missing build output file-%03d.txt for %s", j, platform) + require.Equal(t, fmt.Sprintf("%s-%d", platform, j), string(dt)) + } + if deleteMode { + // Pre-existing files within each platform dir must be cleaned up. + _, err = os.Stat(filepath.Join(platformDir, "old-000.txt")) + require.ErrorIs(t, err, os.ErrNotExist, "stale file not removed for %s", platform) + } + } +} + +func testExportLocalModeInvalid(t *testing.T, sb integration.Sandbox) { + c, err := New(sb.Context(), sb.Address()) + require.NoError(t, err) + defer c.Close() + + st := llb.Scratch().File( + llb.Mkfile("fresh.txt", 0600, []byte("fresh")), + ) + def, err := st.Marshal(sb.Context()) + require.NoError(t, err) + + destDir := t.TempDir() + _, err = c.Solve(sb.Context(), def, SolveOpt{ + Exports: []ExportEntry{ + { + Type: ExporterLocal, + OutputDir: destDir, + Attrs: map[string]string{ + "mode": "backup", + }, + }, + }, + }, nil) + require.Error(t, err) + require.ErrorContains(t, err, `invalid local exporter mode "backup"`) +} + func readFileInImage(ctx context.Context, t *testing.T, c *Client, ref, path string) ([]byte, error) { def, err := llb.Image(ref).Marshal(ctx) if err != nil { diff --git a/client/solve.go b/client/solve.go index 76fae3b7ebf2..c58e29e7e1c6 100644 --- a/client/solve.go +++ b/client/solve.go @@ -5,14 +5,17 @@ import ( "encoding/base64" "encoding/json" "io" + gofs "io/fs" "maps" "os" + "path/filepath" "slices" "strings" "time" "github.com/containerd/containerd/v2/core/content" contentlocal "github.com/containerd/containerd/v2/plugins/content/local" + continuityfs "github.com/containerd/continuity/fs" controlapi "github.com/moby/buildkit/api/services/control" "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/client/ociindex" @@ -108,6 +111,7 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG if opt.Ref != "" { ref = opt.Ref } + reconcileCtx := ctx eg, ctx := errgroup.WithContext(ctx) statusContext, cancelStatus := context.WithCancelCause(context.Background()) @@ -136,6 +140,37 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG storesToUpdate := []string{} + type localDeleteExport struct { + stageDir string + destDir string + } + var localDeleteExports []localDeleteExport + defer func() { + for _, ex := range localDeleteExports { + _ = os.RemoveAll(ex.stageDir) + } + }() + + if opt.SessionPreInitialized { + for _, ex := range opt.Exports { + if ex.Type != ExporterLocal { + continue + } + mode := filesync.FSSyncDirModeCopy + if ex.Attrs != nil { + mode, err = filesync.ParseFSSyncDirMode(ex.Attrs["mode"]) + if err != nil { + return nil, err + } + } + if mode == filesync.FSSyncDirModeDelete { + // Delete mode stages into a temporary directory and reconciles after + // solve returns, which this preinitialized-session path cannot set up. + return nil, errors.New("local exporter mode=delete is not supported with SessionPreInitialized") + } + } + } + if !opt.SessionPreInitialized { if len(syncedDirs) > 0 { s.Allow(filesync.NewFSSyncProvider(syncedDirs)) @@ -189,7 +224,33 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG if ex.OutputDir == "" { return nil, errors.Errorf("output directory is required for %s exporter", ex.Type) } - syncTargets = append(syncTargets, filesync.WithFSSyncDir(exID, ex.OutputDir)) + if ex.Type == ExporterLocal { + mode := filesync.FSSyncDirModeCopy + if ex.Attrs != nil { + mode, err = filesync.ParseFSSyncDirMode(ex.Attrs["mode"]) + if err != nil { + return nil, err + } + } + if mode == filesync.FSSyncDirModeDelete { + // Delete mode is reconciled after solve returns. The DiffCopy + // stream only guarantees the staged copy, not follow-up delete + // work on the sync target. + stageDir, err := os.MkdirTemp("", "buildkit-local-export-") + if err != nil { + return nil, err + } + localDeleteExports = append(localDeleteExports, localDeleteExport{ + stageDir: stageDir, + destDir: ex.OutputDir, + }) + syncTargets = append(syncTargets, filesync.WithFSSyncDir(exID, stageDir)) + } else { + syncTargets = append(syncTargets, filesync.WithFSSyncDir(exID, ex.OutputDir)) + } + } else { + syncTargets = append(syncTargets, filesync.WithFSSyncDir(exID, ex.OutputDir)) + } } if supportStore { store := ex.OutputStore @@ -382,6 +443,11 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG if err := eg.Wait(); err != nil { return nil, err } + for _, ex := range localDeleteExports { + if err := reconcileLocalDeleteExport(reconcileCtx, ex.stageDir, ex.destDir); err != nil { + return nil, err + } + } // Update index.json of exported cache content store // FIXME(AkihiroSuda): dedupe const definition of cache/remotecache.ExporterResponseManifestDesc = "cache.manifest" if manifestDescJSON := res.ExporterResponse["cache.manifest"]; manifestDescJSON != "" { @@ -423,6 +489,75 @@ func (c *Client) solve(ctx context.Context, def *llb.Definition, runGateway runG return res, nil } +func reconcileLocalDeleteExport(ctx context.Context, stageDir, destDir string) error { + if err := os.MkdirAll(destDir, 0o755); err != nil { + return errors.Wrapf(err, "failed to create local export dir %s", destDir) + } + + if err := continuityfs.Changes(ctx, destDir, stageDir, func(kind continuityfs.ChangeKind, p string, _ os.FileInfo, err error) error { + if err != nil { + return err + } + if kind != continuityfs.ChangeKindDelete { + return nil + } + if len(p) > 0 { + p = p[1:] + } + if p == "" { + return nil + } + return os.RemoveAll(filepath.Join(destDir, p)) + }); err != nil { + return errors.WithStack(err) + } + + stageFS, err := fsutil.NewFS(stageDir) + if err != nil { + return err + } + return writeLocalExportFS(ctx, stageFS, destDir) +} + +func writeLocalExportFS(ctx context.Context, src fsutil.FS, dest string) error { + dw, err := fsutil.NewDiskWriter(ctx, dest, fsutil.DiskWriterOpt{ + AsyncDataCb: func(ctx context.Context, p string, wc io.WriteCloser) (retErr error) { + defer func() { + if err := wc.Close(); retErr == nil { + retErr = err + } + }() + + r, err := src.Open(p) + if err != nil { + return err + } + defer r.Close() + + _, err = io.Copy(wc, r) + return err + }, + }) + if err != nil { + return err + } + + if err := src.Walk(ctx, "", func(p string, entry gofs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + return dw.HandleChange(fsutil.ChangeKindAdd, p, info, nil) + }); err != nil { + return err + } + + return dw.Wait(ctx) +} + func prepareSyncedFiles(def *llb.Definition, localMounts map[string]fsutil.FS) (filesync.StaticDirSource, error) { resetUIDAndGID := func(p string, st *fstypes.Stat) fsutil.MapResult { st.Uid = 0 diff --git a/exporter/local/export.go b/exporter/local/export.go index ff043143d71c..edef325caa4c 100644 --- a/exporter/local/export.go +++ b/exporter/local/export.go @@ -2,6 +2,8 @@ package local import ( "context" + "io" + "io/fs" "os" "strings" "sync" @@ -50,6 +52,10 @@ func (e *localExporter) Resolve(ctx context.Context, id int, opt map[string]stri return i, nil } +func (e *localExporter) Config() *exporter.Config { + return exporter.NewConfig() +} + type localExporterInstance struct { *localExporter id int @@ -74,10 +80,6 @@ func (e *localExporterInstance) Attrs() map[string]string { return e.attrs } -func (e *localExporter) Config() *exporter.Config { - return exporter.NewConfig() -} - func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source, buildInfo exporter.ExportBuildInfo) (map[string]string, exporter.FinalizeFunc, exporter.DescriptorReference, error) { timeoutCtx, cancel := context.WithCancelCause(ctx) timeoutCtx, _ = context.WithTimeoutCause(timeoutCtx, 5*time.Second, errors.WithStack(context.DeadlineExceeded)) //nolint:govet @@ -115,6 +117,23 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source visitedPath := map[string]string{} var visitedMu sync.Mutex + if e.opts.Mode == filesync.FSSyncDirModeDelete { + // Delete mode must export one complete final tree; otherwise multi-platform + // outputs can delete sibling platform directories. + outputFS, cleanup, err := e.buildDeleteModeFS(ctx, inp, buildInfo, p, isMap, now) + if err != nil { + return nil, nil, nil, err + } + if cleanup != nil { + defer cleanup() + } + progress := NewProgressHandler(ctx, "copying files") + if err := filesync.CopyToCaller(ctx, outputFS, e.id, caller, progress); err != nil { + return nil, nil, nil, err + } + return nil, nil, nil, nil + } + export := func(ctx context.Context, k string, ref cache.ImmutableRef, attestations []exporter.Attestation, opt CreateFSOpts) func() error { return func() error { outputFS, cleanup, err := CreateFS(ctx, buildInfo.SessionID, k, ref, attestations, now, isMap, opt) @@ -197,6 +216,143 @@ func (e *localExporterInstance) Export(ctx context.Context, inp *exporter.Source return nil, nil, nil, nil } +func (e *localExporterInstance) buildDeleteModeFS(ctx context.Context, inp *exporter.Source, buildInfo exporter.ExportBuildInfo, p exptypes.Platforms, isMap bool, now time.Time) (_ fsutil.FS, cleanup func() error, err error) { + root, err := os.MkdirTemp("", "buildkit-local-export-") + if err != nil { + return nil, nil, err + } + + cleanup = func() error { + return os.RemoveAll(root) + } + defer func() { + if err != nil { + cleanup() + } + }() + + visitedPath := map[string]string{} + + addExport := func(k string, ref cache.ImmutableRef, attestations []exporter.Attestation, opt CreateFSOpts) error { + outputFS, outputCleanup, err := CreateFS(ctx, buildInfo.SessionID, k, ref, attestations, now, isMap, opt) + if err != nil { + return err + } + if outputCleanup != nil { + defer outputCleanup() + } + + if e.opts.UsePlatformSplit(isMap) { + st := &fstypes.Stat{ + Mode: uint32(os.ModeDir | 0755), + Path: strings.ReplaceAll(k, "/", "_"), + } + if opt.Epoch != nil && opt.Epoch.Value != nil { + st.ModTime = opt.Epoch.Value.UnixNano() + } + outputFS, err = fsutil.SubDirFS([]fsutil.Dir{{FS: outputFS, Stat: st}}) + if err != nil { + return err + } + } else { + err = fsWalk(ctx, outputFS, "", func(p string, entry os.DirEntry, err error) error { + if entry.IsDir() { + return nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if vp, ok := visitedPath[p]; ok { + return errors.Errorf("cannot overwrite %s from %s with %s when split option is disabled", p, vp, k) + } + visitedPath[p] = k + return nil + }) + if err != nil { + return err + } + } + + if err := writeFS(ctx, outputFS, root); err != nil { + return err + } + return nil + } + + if len(p.Platforms) > 0 { + for _, p := range p.Platforms { + r, ok := inp.FindRef(p.ID) + if !ok { + return nil, nil, errors.Errorf("failed to find ref for ID %s", p.ID) + } + opt := e.opts + if e.opts.Epoch == nil { + tm, err := epoch.ParseSource(inp, &p) + if err != nil { + return nil, nil, err + } + opt.Epoch = &epoch.Epoch{Value: tm} + } + if err := addExport(p.ID, r, inp.Attestations[p.ID], opt); err != nil { + return nil, nil, err + } + } + } else { + if err := addExport("", inp.Ref, nil, e.opts); err != nil { + return nil, nil, err + } + } + + outputFS, err := fsutil.NewFS(root) + if err != nil { + return nil, nil, err + } + return outputFS, cleanup, nil +} + +func writeFS(ctx context.Context, src fsutil.FS, dest string) error { + if err := os.MkdirAll(dest, 0700); err != nil { + return errors.Wrapf(err, "failed to create export staging dir %s", dest) + } + + dw, err := fsutil.NewDiskWriter(ctx, dest, fsutil.DiskWriterOpt{ + AsyncDataCb: func(ctx context.Context, p string, wc io.WriteCloser) (retErr error) { + defer func() { + if err := wc.Close(); retErr == nil { + retErr = err + } + }() + + r, err := src.Open(p) + if err != nil { + return err + } + defer r.Close() + + _, err = io.Copy(wc, r) + return err + }, + }) + if err != nil { + return err + } + + if err := src.Walk(ctx, "", func(p string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + return dw.HandleChange(fsutil.ChangeKindAdd, p, info, nil) + }); err != nil { + return err + } + + return dw.Wait(ctx) +} + func NewProgressHandler(ctx context.Context, id string) func(int, bool) { limiter := rate.NewLimiter(rate.Every(100*time.Millisecond), 1) pw, _, _ := progress.NewFromContext(ctx) diff --git a/exporter/local/fs.go b/exporter/local/fs.go index 199003a26f71..98a213a59794 100644 --- a/exporter/local/fs.go +++ b/exporter/local/fs.go @@ -18,6 +18,7 @@ import ( "github.com/moby/buildkit/exporter/attestation" "github.com/moby/buildkit/exporter/util/epoch" "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/filesync" "github.com/moby/buildkit/snapshot" "github.com/moby/buildkit/solver/result" "github.com/moby/buildkit/util/staticfs" @@ -33,12 +34,14 @@ const ( // keyPlatformSplit is an exporter option which can be used to split result // in subfolders when multiple platform references are exported. keyPlatformSplit = "platform-split" + keyMode = "mode" ) type CreateFSOpts struct { Epoch *epoch.Epoch AttestationPrefix string PlatformSplit *bool + Mode filesync.FSSyncDirMode } func (c *CreateFSOpts) UsePlatformSplit(isMap bool) bool { @@ -50,6 +53,7 @@ func (c *CreateFSOpts) UsePlatformSplit(isMap bool) bool { func (c *CreateFSOpts) Load(opt map[string]string) (map[string]string, error) { rest := make(map[string]string) + c.Mode = filesync.FSSyncDirModeCopy var err error c.Epoch, opt, err = epoch.ParseExporterAttrs(opt) @@ -67,6 +71,12 @@ func (c *CreateFSOpts) Load(opt map[string]string) (map[string]string, error) { return nil, errors.Wrapf(err, "non-bool value for %s: %s", keyPlatformSplit, v) } c.PlatformSplit = &b + case keyMode: + mode, err := filesync.ParseFSSyncDirMode(v) + if err != nil { + return nil, err + } + c.Mode = mode default: rest[k] = v } diff --git a/session/filesync/diffcopy.go b/session/filesync/diffcopy.go index 5e8b40b93d94..af0582b655c0 100644 --- a/session/filesync/diffcopy.go +++ b/session/filesync/diffcopy.go @@ -8,7 +8,6 @@ import ( "time" "github.com/moby/buildkit/util/bklog" - "github.com/pkg/errors" "github.com/tonistiigi/fsutil" fstypes "github.com/tonistiigi/fsutil/types" diff --git a/session/filesync/filesync.go b/session/filesync/filesync.go index 230493f1690f..8213ca94f2a7 100644 --- a/session/filesync/filesync.go +++ b/session/filesync/filesync.go @@ -251,6 +251,24 @@ type FSSyncTarget interface { target() *fsSyncTarget } +type FSSyncDirMode string + +const ( + FSSyncDirModeCopy FSSyncDirMode = "copy" + FSSyncDirModeDelete FSSyncDirMode = "delete" +) + +func ParseFSSyncDirMode(v string) (FSSyncDirMode, error) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", string(FSSyncDirModeCopy): + return FSSyncDirModeCopy, nil + case string(FSSyncDirModeDelete): + return FSSyncDirModeDelete, nil + default: + return "", errors.Errorf("invalid local exporter mode %q", v) + } +} + type fsSyncTarget struct { id int outdir string