Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 0 additions & 12 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<mount-hash>.")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -3482,7 +3471,6 @@ func vaultMountOptions(ctx commandContext, profile *config.Profile) (tifs.VaultM
Profile: profile,
MountPath: mountPath,
VaultToken: token,
Foreground: foreground,
ReadyTimeout: readyTimeout,
}, nil
}
Expand Down
24 changes: 7 additions & 17 deletions internal/fs/drive9_companion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions internal/fs/drive9_companion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
101 changes: 0 additions & 101 deletions internal/fs/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
Expand All @@ -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"
)
Expand All @@ -43,7 +41,6 @@ type MountFileSystemOptions struct {
MountPath string
RemotePath string
Driver string
Foreground bool
ReadOnly bool
ReadyTimeout time.Duration
CacheDir string
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 0 additions & 36 deletions internal/fs/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ type VaultMountOptions struct {
Profile *config.Profile
MountPath string
VaultToken string
Foreground bool
ReadyTimeout time.Duration
}

Expand Down Expand Up @@ -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")
Expand Down