Skip to content

Commit 4411d7b

Browse files
yasithdevclaude
andcommitted
Replace sshfs/fuse-overlayfs with pure Go SFTP sync
The overlay mount no longer requires external tools (sshfs, fuse-overlayfs, fusermount). Instead, it connects to the local linkspan's SSH server via pkg/sftp and recursively syncs workspace files to ~/overlay/<session-id>/. Skips .git, node_modules, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a178ef5 commit 4411d7b

File tree

2 files changed

+125
-59
lines changed

2 files changed

+125
-59
lines changed

subsystems/mount/overlay.go

Lines changed: 123 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,27 @@ package mount
22

33
import (
44
"fmt"
5+
"io"
56
"log"
67
"os"
7-
"os/exec"
88
"path/filepath"
9+
"strings"
910

10-
pm "github.com/cyber-shuttle/linkspan/internal/process"
11+
"github.com/pkg/sftp"
12+
"golang.org/x/crypto/ssh"
1113
)
1214

13-
// OverlayMount holds the state for a single overlay filesystem setup.
15+
// OverlayMount holds the state for a workspace sync from the local machine.
1416
type OverlayMount struct {
15-
SessionID string
16-
SourceDir string // sshfs mount of local workspace (lower)
17-
CacheDir string // mutagen-warmed cache (upper)
18-
WorkDir string // overlayfs workdir
19-
MergedDir string // final merged view
20-
SshfsCmdID string // process manager ID for sshfs
21-
OverlayCmdID string // process manager ID for fuse-overlayfs
17+
SessionID string
18+
SourceDir string // local workspace path on the origin machine
19+
CacheDir string // local cache dir on the compute node
20+
MergedDir string // final workspace view on the compute node
2221
}
2322

24-
// SetupOverlay creates the overlay filesystem:
25-
// 1. sshfs mounts the local workspace via tunnel
26-
// 2. fuse-overlayfs merges lower (sshfs) + upper (cache)
23+
// SetupOverlay syncs the local workspace to the compute node via SFTP over
24+
// the devtunnel-forwarded SSH port. No external tools (sshfs, fuse-overlayfs)
25+
// are needed — everything is done in pure Go.
2726
func SetupOverlay(sessionID string, localSshPort int, localWorkspace string) (*OverlayMount, error) {
2827
home, err := os.UserHomeDir()
2928
if err != nil {
@@ -32,70 +31,137 @@ func SetupOverlay(sessionID string, localSshPort int, localWorkspace string) (*O
3231

3332
m := &OverlayMount{
3433
SessionID: sessionID,
35-
SourceDir: filepath.Join(os.TempDir(), fmt.Sprintf("cs-source-%s", sessionID)),
34+
SourceDir: localWorkspace,
3635
CacheDir: filepath.Join(home, "sessions", sessionID),
37-
WorkDir: filepath.Join(home, "sessions", sessionID, ".overlay-work"),
3836
MergedDir: filepath.Join(home, "overlay", sessionID),
3937
}
4038

41-
// Create all directories
42-
for _, dir := range []string{m.SourceDir, m.CacheDir, m.WorkDir, m.MergedDir} {
43-
if err := os.MkdirAll(dir, 0755); err != nil {
44-
return nil, fmt.Errorf("overlay: mkdir %s: %w", dir, err)
45-
}
39+
if err := os.MkdirAll(m.MergedDir, 0755); err != nil {
40+
return nil, fmt.Errorf("overlay: mkdir %s: %w", m.MergedDir, err)
4641
}
4742

48-
// 1. sshfs mount local workspace via tunnel
49-
sshfsArgs := []string{
50-
fmt.Sprintf("localhost:%s", localWorkspace),
51-
m.SourceDir,
52-
"-p", fmt.Sprintf("%d", localSshPort),
53-
"-o", "StrictHostKeyChecking=no",
54-
"-o", "UserKnownHostsFile=/dev/null",
55-
"-o", "reconnect",
56-
"-o", "ServerAliveInterval=15",
57-
"-o", "ServerAliveCountMax=3",
43+
log.Printf("[overlay] syncing %s from localhost:%d → %s", localWorkspace, localSshPort, m.MergedDir)
44+
45+
// Connect to the local linkspan's SSH server via devtunnel-forwarded port.
46+
// The linkspan SSH server accepts any key (no auth required).
47+
sshConfig := &ssh.ClientConfig{
48+
User: "user",
49+
Auth: []ssh.AuthMethod{ssh.Password("")},
50+
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
5851
}
5952

60-
sshfsCmd := exec.Command("sshfs", sshfsArgs...)
61-
sshfsCmdID, err := pm.GlobalProcessManager.Start(sshfsCmd)
53+
addr := fmt.Sprintf("127.0.0.1:%d", localSshPort)
54+
conn, err := ssh.Dial("tcp", addr, sshConfig)
6255
if err != nil {
63-
return nil, fmt.Errorf("overlay: start sshfs: %w", err)
56+
// Retry with no-auth in case password is rejected
57+
sshConfig.Auth = []ssh.AuthMethod{}
58+
conn, err = ssh.Dial("tcp", addr, sshConfig)
59+
if err != nil {
60+
return nil, fmt.Errorf("overlay: ssh connect to %s: %w", addr, err)
61+
}
6462
}
65-
m.SshfsCmdID = sshfsCmdID
66-
log.Printf("[overlay] sshfs mounted %s on port %d → %s", localWorkspace, localSshPort, m.SourceDir)
63+
defer conn.Close()
6764

68-
// 2. fuse-overlayfs
69-
overlayArgs := []string{
70-
"-o", fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", m.SourceDir, m.CacheDir, m.WorkDir),
71-
m.MergedDir,
65+
client, err := sftp.NewClient(conn)
66+
if err != nil {
67+
return nil, fmt.Errorf("overlay: sftp client: %w", err)
7268
}
69+
defer client.Close()
7370

74-
overlayCmd := exec.Command("fuse-overlayfs", overlayArgs...)
75-
overlayCmdID, err := pm.GlobalProcessManager.Start(overlayCmd)
71+
// Recursive sync from remote (local machine) to local (compute node)
72+
copied, err := syncDir(client, localWorkspace, m.MergedDir)
7673
if err != nil {
77-
// Clean up sshfs on failure
78-
_ = pm.GlobalProcessManager.Kill(sshfsCmdID)
79-
return nil, fmt.Errorf("overlay: start fuse-overlayfs: %w", err)
74+
return nil, fmt.Errorf("overlay: sync: %w", err)
8075
}
81-
m.OverlayCmdID = overlayCmdID
82-
log.Printf("[overlay] fuse-overlayfs merged at %s (lower=%s, upper=%s)", m.MergedDir, m.SourceDir, m.CacheDir)
8376

77+
log.Printf("[overlay] synced %d files to %s", copied, m.MergedDir)
8478
return m, nil
8579
}
8680

87-
// Teardown unmounts the overlay and sshfs.
88-
func (m *OverlayMount) Teardown() {
89-
if m.OverlayCmdID != "" {
90-
_ = pm.GlobalProcessManager.Kill(m.OverlayCmdID)
91-
log.Printf("[overlay] stopped fuse-overlayfs for %s", m.SessionID)
81+
// syncDir recursively copies files from the SFTP remote path to a local directory.
82+
func syncDir(client *sftp.Client, remotePath, localPath string) (int, error) {
83+
entries, err := client.ReadDir(remotePath)
84+
if err != nil {
85+
return 0, fmt.Errorf("readdir %s: %w", remotePath, err)
86+
}
87+
88+
count := 0
89+
for _, entry := range entries {
90+
name := entry.Name()
91+
92+
// Skip common non-essential dirs to speed up initial sync
93+
if entry.IsDir() && shouldSkipDir(name) {
94+
continue
95+
}
96+
97+
remoteFile := filepath.Join(remotePath, name)
98+
localFile := filepath.Join(localPath, name)
99+
100+
if entry.IsDir() {
101+
if err := os.MkdirAll(localFile, entry.Mode()|0700); err != nil {
102+
return count, fmt.Errorf("mkdir %s: %w", localFile, err)
103+
}
104+
n, err := syncDir(client, remoteFile, localFile)
105+
if err != nil {
106+
return count, err
107+
}
108+
count += n
109+
} else if entry.Mode().IsRegular() {
110+
if err := syncFile(client, remoteFile, localFile, entry.Mode()); err != nil {
111+
log.Printf("[overlay] warning: skip %s: %v", remoteFile, err)
112+
continue
113+
}
114+
count++
115+
}
116+
// Skip symlinks, devices, etc. for now
92117
}
93-
// fusermount -u to cleanly unmount
94-
_ = exec.Command("fusermount", "-u", m.MergedDir).Run()
95-
_ = exec.Command("fusermount", "-u", m.SourceDir).Run()
118+
return count, nil
119+
}
96120

97-
if m.SshfsCmdID != "" {
98-
_ = pm.GlobalProcessManager.Kill(m.SshfsCmdID)
99-
log.Printf("[overlay] stopped sshfs for %s", m.SessionID)
121+
// shouldSkipDir returns true for directories that should not be synced.
122+
func shouldSkipDir(name string) bool {
123+
skip := []string{
124+
".git", "node_modules", "__pycache__", ".venv", "venv",
125+
".tox", ".mypy_cache", ".pytest_cache", ".eggs",
126+
"target", "build", "dist", ".gradle", ".idea", ".vscode",
127+
}
128+
for _, s := range skip {
129+
if strings.EqualFold(name, s) {
130+
return true
131+
}
100132
}
133+
return false
101134
}
135+
136+
// syncFile copies a single file from SFTP to local filesystem.
137+
func syncFile(client *sftp.Client, remotePath, localPath string, mode os.FileMode) error {
138+
// Skip if local file exists and has same size (quick check)
139+
if info, err := os.Stat(localPath); err == nil {
140+
remoteInfo, err := client.Stat(remotePath)
141+
if err == nil && info.Size() == remoteInfo.Size() && !info.ModTime().Before(remoteInfo.ModTime()) {
142+
return nil // already up to date
143+
}
144+
}
145+
146+
src, err := client.Open(remotePath)
147+
if err != nil {
148+
return err
149+
}
150+
defer src.Close()
151+
152+
dst, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
153+
if err != nil {
154+
return err
155+
}
156+
defer dst.Close()
157+
158+
_, err = io.Copy(dst, src)
159+
return err
160+
}
161+
162+
// Teardown removes the synced directory.
163+
func (m *OverlayMount) Teardown() {
164+
log.Printf("[overlay] cleaning up %s", m.MergedDir)
165+
// Don't remove MergedDir — user may have modified files that need to persist
166+
}
167+

subsystems/tunnel/devtunnel_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestDevTunnelCreateAndHost(t *testing.T) {
3030
}
3131
}()
3232

33-
conn, err := DevTunnelCreate(tunnelName, "1d", authToken, 8080)
33+
conn, err := DevTunnelCreate(tunnelName, "1d", authToken, 8080, 0)
3434
if err != nil {
3535
t.Fatalf("failed to create dev tunnel: %v", err)
3636
}
@@ -51,7 +51,7 @@ func TestDevTunnelCreateNoPort(t *testing.T) {
5151
authToken := authTokenForTest(t)
5252
tunnelName := "test-tunnel-noport"
5353

54-
conn, err := DevTunnelCreate(tunnelName, "1d", authToken, 0)
54+
conn, err := DevTunnelCreate(tunnelName, "1d", authToken, 0, 0)
5555
if err != nil {
5656
t.Fatalf("failed to create dev tunnel: %v", err)
5757
}

0 commit comments

Comments
 (0)