@@ -2,28 +2,27 @@ package mount
22
33import (
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 .
1416type 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.
2726func 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+
0 commit comments