From ce351b8c10c0a152db024b6fda2341472ccc9b59 Mon Sep 17 00:00:00 2001 From: Cheese Date: Tue, 18 Aug 2026 16:59:10 +0800 Subject: [PATCH] refactor(fs): remove foreground mount mode --- AGENTS.md | 5 ++ README.md | 2 + e2e/cli_test.go | 15 +++- internal/cli/commands.go | 12 ---- internal/fs/drive9_companion.go | 24 ++----- internal/fs/drive9_companion_test.go | 37 ++++++++++ internal/fs/mount.go | 101 --------------------------- internal/fs/vault.go | 36 ---------- 8 files changed, 64 insertions(+), 168 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a7901e..93f4bd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -407,6 +407,11 @@ Follow these rules unless `docs/priciples.md` is updated: resource and local credentials when waiting fails. - `ti fs delete-file-system` is asynchronous. After Drive9 accepts deletion, output status is `deleting`, not `deleted`. +- `ti fs mount-file-system` and `ti fs-vault mount-vault` are background-only + public commands. They wait for readiness, return a structured result, and + persist a local mount locator for the matching unmount command. Do not expose + a public `--foreground` flag; Drive9 may still use a foreground worker as an + internal implementation detail. - One remote Filesystem can have multiple tokens, but one profile stores at most one selected local token per Filesystem. Remote token inventory and lifecycle state are authoritative; local credentials are not a token wallet. diff --git a/README.md b/README.md index 0c95d14..9c765ff 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,8 @@ ti fs mount-file-system --file-system-id "$FILE_SYSTEM_ID" --mount-path ~/my-wor Automatic mounting uses FUSE on Linux and WebDAV on macOS and Windows. macOS users can install macFUSE and explicitly add `--driver fuse` for the full FUSE experience. +Mount commands start the companion runtime in the background, wait until the mount is ready, and then return a structured result. Use `ti fs unmount-file-system` or `ti fs-vault unmount-vault` to end a mount. The public CLI does not expose a foreground mount mode. + `ti fs list-file-systems` reads the region-scoped remote inventory through TiDB Cloud credentials. A profile can access multiple file systems, including resources created on another machine. Data-plane commands never infer a resource from the number of local credentials, so provide `--file-system-id` or set `TI_FS_FILE_SYSTEM_ID`: ```shell diff --git a/e2e/cli_test.go b/e2e/cli_test.go index be6c5f3..6a4c40f 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -57,10 +57,21 @@ func TestHelpAndVersion(t *testing.T) { subcommand.wantExitCode(0) subcommand.wantStdoutContains("Mount a file system to a local path.") subcommand.wantStdoutContains("--mount-path") - subcommand.wantStdoutContains("--foreground") + subcommand.wantStdoutNotContains("--foreground") subcommand.wantStdoutContains("--mount-profile") subcommand.wantStdoutContains("--local-root") subcommand.wantStdoutContains("--pack-path") + vaultMount := runTI(t, bin, "fs-vault", "mount-vault", "help") + vaultMount.wantExitCode(0) + vaultMount.wantStdoutNotContains("--foreground") + for _, args := range [][]string{ + {"fs", "mount-file-system", "--mount-path", "/tmp/ti-mount", "--foreground"}, + {"fs-vault", "mount-vault", "--mount-path", "/tmp/ti-vault", "--foreground"}, + } { + removedForeground := runTI(t, bin, args...) + removedForeground.wantExitCode(2) + removedForeground.wantStderrContains("unknown flag: --foreground") + } copyFile := runTI(t, bin, "fs", "copy-file", "help") copyFile.wantExitCode(0) @@ -781,7 +792,7 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi journal.wantExitCode(0) git := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs-git", "hydrate-git-workspace", "--file-system-id", "tenant-aws-us-west-2", "--target-path", filepath.Join(home, "workspace")) git.wantExitCode(0) - mount := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "mount-file-system", "--file-system-id", "tenant-aws-us-west-2", "--mount-path", filepath.Join(home, "mount"), "--foreground") + mount := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "mount-file-system", "--file-system-id", "tenant-aws-us-west-2", "--mount-path", filepath.Join(home, "mount")) mount.wantExitCode(0) calls := readFakeDrive9Calls(t, recordPath) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 9b819f5..2cf4ac6 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -2211,7 +2211,6 @@ func newFSMountFileSystemCommand(info version.Info) *cobra.Command { cmd.Flags().String("mount-path", "", "Local mount path.") cmd.Flags().String("remote-path", "/", "The TiDB Cloud file system root path to mount.") cmd.Flags().String("driver", "auto", "Mount driver: auto, fuse, or webdav.") - cmd.Flags().Bool("foreground", false, "Run the mount runtime in the foreground until interrupted.") cmd.Flags().Bool("read-only", false, "Read-only mount mode.") cmd.Flags().Duration("ready-timeout", 30*time.Second, "Time to wait for a background mount to become ready.") cmd.Flags().String("cache-dir", "", "Local FUSE cache directory. Default: ~/.ti/cache/mounts/.") @@ -2654,10 +2653,6 @@ func fsMountOptions(ctx commandContext, profile *config.Profile) (tifs.MountFile if err != nil { return tifs.MountFileSystemOptions{}, err } - foreground, err := ctx.BoolFlag("foreground") - if err != nil { - return tifs.MountFileSystemOptions{}, err - } readOnly, err := ctx.BoolFlag("read-only") if err != nil { return tifs.MountFileSystemOptions{}, err @@ -2712,7 +2707,6 @@ func fsMountOptions(ctx commandContext, profile *config.Profile) (tifs.MountFile MountPath: mountPath, RemotePath: remotePath, Driver: driver, - Foreground: foreground, ReadOnly: readOnly, ReadyTimeout: readyTimeout, CacheDir: cacheDir, @@ -3351,7 +3345,6 @@ func newVaultMountCommand(info version.Info) *cobra.Command { }, }, info) cmd.Flags().String("mount-path", "", "The local mount path.") - cmd.Flags().Bool("foreground", false, "Run mount runtime in the foreground until interrupted.") cmd.Flags().Duration("ready-timeout", 30*time.Second, "The time to wait for a background mount to become ready.") cmd.Flags().String("vault-token", "", "Delegated file system vault token; prefer TI_VAULT_TOKEN environment variable.") markUsageRequired(cmd, "mount-path") @@ -3466,10 +3459,6 @@ func vaultMountOptions(ctx commandContext, profile *config.Profile) (tifs.VaultM if err != nil { return tifs.VaultMountOptions{}, err } - foreground, err := ctx.BoolFlag("foreground") - if err != nil { - return tifs.VaultMountOptions{}, err - } readyTimeout, err := ctx.DurationFlag("ready-timeout") if err != nil { return tifs.VaultMountOptions{}, err @@ -3482,7 +3471,6 @@ func vaultMountOptions(ctx commandContext, profile *config.Profile) (tifs.VaultM Profile: profile, MountPath: mountPath, VaultToken: token, - Foreground: foreground, ReadyTimeout: readyTimeout, }, nil } diff --git a/internal/fs/drive9_companion.go b/internal/fs/drive9_companion.go index 9640b4b..30788d0 100644 --- a/internal/fs/drive9_companion.go +++ b/internal/fs/drive9_companion.go @@ -1055,18 +1055,13 @@ func (s Service) drive9MountVault(ctx context.Context, opts VaultMountOptions) ( return MountResult{}, apperr.New("vault.missing_token", "usage", 2, "ti fs-vault mount-vault requires --vault-token or TI_VAULT_TOKEN; use create-grant to mint a delegated vault token first") } args := []string{"mount", "vault"} - if opts.Foreground { - args = append(args, "--foreground") - } args = append(args, opts.MountPath) if _, err := s.drive9Runner().Run(ctx, fswrap.RunOptions{Profile: opts.Profile, Args: args, IncludeFSAPIKey: true, VaultToken: opts.VaultToken}); err != nil { return MountResult{}, err } - if !opts.Foreground { - if err := s.writeDrive9MountLocator(opts.Profile, opts.MountPath, "vault"); err != nil { - _, _ = s.drive9Run(ctx, opts.Profile, []string{"umount", opts.MountPath}, false) - return MountResult{}, err - } + if err := s.writeDrive9MountLocator(opts.Profile, opts.MountPath, "vault"); err != nil { + _, _ = s.drive9Run(ctx, opts.Profile, []string{"umount", opts.MountPath}, false) + return MountResult{}, err } return MountResult{Status: "mounted", Profile: profileName(opts.Profile), FileSystemName: "vault", MountPath: opts.MountPath, RemotePath: "/n/vault", Driver: "fuse"}, nil } @@ -1124,9 +1119,6 @@ func (s Service) drive9MountFileSystem(ctx context.Context, opts MountFileSystem if opts.Driver != "" { args = append(args, "--mode", opts.Driver) } - if opts.Foreground { - args = append(args, "--foreground") - } if opts.ReadOnly { args = append(args, "--read-only") } @@ -1155,15 +1147,13 @@ func (s Service) drive9MountFileSystem(ctx context.Context, opts MountFileSystem args = append(args, "--no-auto-unpack") } args = append(args, drive9Remote(remotePath), opts.MountPath) - result, err := s.drive9Run(ctx, opts.Profile, args, !opts.Foreground) + result, err := s.drive9Run(ctx, opts.Profile, args, true) if err != nil { return MountResult{}, err } - if !opts.Foreground { - if err := s.writeDrive9MountLocator(opts.Profile, opts.MountPath, "fs"); err != nil { - _, _ = s.drive9Run(ctx, opts.Profile, []string{"umount", opts.MountPath}, false) - return MountResult{}, err - } + if err := s.writeDrive9MountLocator(opts.Profile, opts.MountPath, "fs"); err != nil { + _, _ = s.drive9Run(ctx, opts.Profile, []string{"umount", opts.MountPath}, false) + return MountResult{}, err } endpoint, _ := s.resolveFS(opts.Profile) driver := drive9MountedDriver(result.Stderr, opts.Driver) diff --git a/internal/fs/drive9_companion_test.go b/internal/fs/drive9_companion_test.go index 902eb3f..67ed677 100644 --- a/internal/fs/drive9_companion_test.go +++ b/internal/fs/drive9_companion_test.go @@ -380,6 +380,12 @@ func TestDrive9MountLocatorRoutesDrainAndUnmountWithoutCredentials(t *testing.T) if strings.Contains(string(data), profile.FSAPIKey) { t.Fatalf("mount locator leaked FS token: %s", data) } + mountCall := requireFakeDrive9Call(t, recordPath, "mount") + for _, arg := range mountCall.Args { + if arg == "--foreground" { + t.Fatalf("ti exposed foreground mode to Drive9: %#v", mountCall.Args) + } + } localProfile := &config.Profile{Name: "default", HomeDir: home} if _, err := service.DrainFileSystem(context.Background(), DrainFileSystemOptions{ @@ -408,6 +414,37 @@ func TestDrive9MountLocatorRoutesDrainAndUnmountWithoutCredentials(t *testing.T) } } +func TestDrive9VaultMountUsesBackgroundMode(t *testing.T) { + home := t.TempDir() + companion, recordPath := buildFakeDrive9(t) + t.Setenv("TI_FAKE_DRIVE9_RECORD", recordPath) + service := testCompanionService(home, companion) + service.Stdout = &bytes.Buffer{} + service.Stderr = &bytes.Buffer{} + mountPath := filepath.Join(t.TempDir(), "vault") + + if _, err := service.MountVault(context.Background(), VaultMountOptions{ + Profile: dataProfile(), + MountPath: mountPath, + VaultToken: "vault-token", + }); err != nil { + t.Fatalf("MountVault failed: %v", err) + } + call := requireFakeDrive9Call(t, recordPath, "mount", "vault") + for _, arg := range call.Args { + if arg == "--foreground" { + t.Fatalf("ti exposed foreground vault mode to Drive9: %#v", call.Args) + } + } + locator, _, err := mountlocator.Read(home, mountPath) + if err != nil { + t.Fatalf("read vault mount locator: %v", err) + } + if locator.Kind != "vault" { + t.Fatalf("vault mount locator kind = %q, want vault", locator.Kind) + } +} + func TestDrive9MountSuppressesCompanionSuccessChatter(t *testing.T) { home := t.TempDir() companion, _ := buildFakeDrive9(t) diff --git a/internal/fs/mount.go b/internal/fs/mount.go index d94e786..d48e2c7 100644 --- a/internal/fs/mount.go +++ b/internal/fs/mount.go @@ -10,7 +10,6 @@ import ( "net" "net/http" "os" - "os/exec" "os/signal" "path/filepath" "strings" @@ -26,7 +25,6 @@ import ( "github.com/tidbcloud/ti-cli/internal/dryrun" "github.com/tidbcloud/ti-cli/internal/fs/mountcontrol" "github.com/tidbcloud/ti-cli/internal/fs/mountdriver" - "github.com/tidbcloud/ti-cli/internal/fs/mountprocess" "github.com/tidbcloud/ti-cli/internal/fs/mountstate" "golang.org/x/net/webdav" ) @@ -43,7 +41,6 @@ type MountFileSystemOptions struct { MountPath string RemotePath string Driver string - Foreground bool ReadOnly bool ReadyTimeout time.Duration CacheDir string @@ -138,18 +135,6 @@ type MountRuntimeCheck struct { Message string `json:"message,omitempty"` } -type backgroundMountRequest struct { - Executable string - Args []string - Env []string - LogFile string - StateFile string - MountPath string - Timeout time.Duration -} - -type backgroundMountStarter func(context.Context, backgroundMountRequest) (int, error) - func (s Service) MountFileSystem(ctx context.Context, opts MountFileSystemOptions) (MountResult, error) { return s.drive9MountFileSystem(ctx, opts) } @@ -557,92 +542,6 @@ func (s Service) mountWebDAVForeground(ctx context.Context, inputs mountInputs, return mountResult("unmounted", inputs, remote, checks, os.Getpid(), stateFile, ""), nil } -func (s Service) mountBackground(ctx context.Context, inputs mountInputs, remote apifs.StatusResponse, checks []MountRuntimeCheck) (MountResult, error) { - executable, err := os.Executable() - if err != nil { - return MountResult{}, apperr.Wrap("fs.executable_path", "runtime", 1, "determine ti executable path for background mount", err) - } - if err := os.MkdirAll(filepath.Dir(inputs.logFile), 0o700); err != nil { - return MountResult{}, apperr.Wrap("fs.mount_log_dir", "runtime", 1, fmt.Sprintf("create mount log directory %q", filepath.Dir(inputs.logFile)), err) - } - args := []string{ - "--profile", inputs.profile.Name, - "fs", "mount-file-system", - "--file-system-id", inputs.fileSystemName, - "--mount-path", inputs.mountPath, - "--remote-path", inputs.remotePath, - "--driver", inputs.driver.Name(), - "--cache-dir", inputs.cacheDir, - "--read-cache-size-mb", fmt.Sprintf("%d", inputs.readCacheBytes/(1<<20)), - "--read-cache-max-file-mb", fmt.Sprintf("%d", inputs.readCacheFileBytes/(1<<20)), - "--read-cache-ttl", inputs.readCacheTTL.String(), - "--write-back-cache=" + fmt.Sprintf("%t", inputs.writeBackCache), - "--mount-profile", inputs.mountProfile, - "--foreground", - } - if inputs.localRoot != "" { - args = append(args, "--local-root", inputs.localRoot) - } - if inputs.unpackArchivePath != "" { - args = append(args, "--unpack-archive-path", inputs.unpackArchivePath) - } - if inputs.noAutoUnpack { - args = append(args, "--no-auto-unpack") - } - for _, packPath := range inputs.packPaths { - args = append(args, "--pack-path", packPath) - } - if inputs.readOnly { - args = append(args, "--read-only") - } - pid, err := startBackgroundMount(ctx, backgroundMountRequest{ - Executable: executable, - Args: args, - LogFile: inputs.logFile, - StateFile: inputs.stateFile, - MountPath: inputs.mountPath, - Timeout: inputs.timeout, - }) - if err != nil { - return MountResult{}, err - } - checks = append(checks, MountRuntimeCheck{Name: "background_process", Status: "passed", Message: fmt.Sprintf("pid %d", pid)}) - checks = append(checks, MountRuntimeCheck{Name: "mount_state", Status: "passed", Message: inputs.stateFile}) - return mountResult("mounted", inputs, remote, checks, pid, inputs.stateFile, inputs.logFile), nil -} - -func startBackgroundMount(ctx context.Context, request backgroundMountRequest) (int, error) { - logFile, err := os.OpenFile(request.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return 0, apperr.Wrap("fs.mount_log", "runtime", 1, fmt.Sprintf("open mount log %q", request.LogFile), err) - } - defer logFile.Close() - cmd := exec.CommandContext(ctx, request.Executable, request.Args...) - if len(request.Env) > 0 { - cmd.Env = append(os.Environ(), request.Env...) - } - cmd.Stdout = logFile - cmd.Stderr = logFile - if err := cmd.Start(); err != nil { - return 0, apperr.Wrap("fs.start_mount_process", "runtime", 1, "start background ti fs mount process", err) - } - deadline := time.Now().Add(request.Timeout) - for { - if _, err := os.Stat(request.StateFile); err == nil { - _ = cmd.Process.Release() - return cmd.Process.Pid, nil - } - if !mountprocess.Alive(cmd.Process.Pid) { - return 0, apperr.New("fs.mount_process_exited", "runtime", 1, fmt.Sprintf("background mount process exited before %q became ready; inspect %s", request.MountPath, request.LogFile)) - } - if time.Now().After(deadline) { - _ = mountprocess.Terminate(cmd.Process.Pid) - return 0, apperr.New("fs.mount_ready_timeout", "runtime", 1, fmt.Sprintf("ti fs mount at %q did not become ready within %s; inspect %s", request.MountPath, request.Timeout, request.LogFile)) - } - time.Sleep(100 * time.Millisecond) - } -} - func mountResult(status string, inputs mountInputs, remote apifs.StatusResponse, checks []MountRuntimeCheck, pid int, stateFile, logFile string) MountResult { controlSocket := "" if state, _, err := mountstate.Read(inputs.homeDir, inputs.mountPath); err == nil { diff --git a/internal/fs/vault.go b/internal/fs/vault.go index 333d81e..f67adbf 100644 --- a/internal/fs/vault.go +++ b/internal/fs/vault.go @@ -97,7 +97,6 @@ type VaultMountOptions struct { Profile *config.Profile MountPath string VaultToken string - Foreground bool ReadyTimeout time.Duration } @@ -185,41 +184,6 @@ func (s Service) DryRunMountVault(ctx context.Context, commandPath string, opts ), nil } -func (s Service) mountVaultBackground(ctx context.Context, inputs vaultMountInputs, checks []MountRuntimeCheck) (MountResult, error) { - executable, err := os.Executable() - if err != nil { - return MountResult{}, apperr.Wrap("vault.executable_path", "runtime", 1, "determine ti executable path for background vault mount", err) - } - if err := os.MkdirAll(filepath.Dir(inputs.logFile), 0o700); err != nil { - return MountResult{}, apperr.Wrap("vault.mount_log_dir", "runtime", 1, fmt.Sprintf("create mount log directory %q", filepath.Dir(inputs.logFile)), err) - } - args := []string{ - "--profile", inputs.profile.Name, - "fs-vault", "mount-vault", - "--mount-path", inputs.mountPath, - "--foreground", - } - env := []string(nil) - if strings.TrimSpace(inputs.vaultToken) != "" { - env = append(env, "TI_VAULT_TOKEN="+strings.TrimSpace(inputs.vaultToken)) - } - pid, err := startBackgroundMount(ctx, backgroundMountRequest{ - Executable: executable, - Args: args, - Env: env, - LogFile: inputs.logFile, - StateFile: inputs.stateFile, - MountPath: inputs.mountPath, - Timeout: inputs.timeout, - }) - if err != nil { - return MountResult{}, err - } - checks = append(checks, MountRuntimeCheck{Name: "background_process", Status: "passed", Message: fmt.Sprintf("pid %d", pid)}) - checks = append(checks, MountRuntimeCheck{Name: "mount_state", Status: "passed", Message: inputs.stateFile}) - return vaultMountResult("mounted", inputs, checks, pid, inputs.stateFile, inputs.logFile), nil -} - func (s Service) vaultMountInputs(ctx context.Context, opts VaultMountOptions) (vaultMountInputs, []MountRuntimeCheck, error) { if opts.Profile == nil { return vaultMountInputs{}, nil, apperr.New("vault.missing_profile", "config", 2, "active profile is required")