@@ -4,164 +4,211 @@ import (
44 "fmt"
55 "io"
66 "log"
7+ "net/http"
78 "os"
9+ "os/exec"
810 "path/filepath"
9- "strings"
11+ "runtime"
12+ "sync"
13+ "time"
1014
11- "github.com/pkg/sftp"
12- "golang.org/x/crypto/ssh"
15+ pm "github.com/cyber-shuttle/linkspan/internal/process"
1316)
1417
15- // OverlayMount holds the state for a workspace sync from the local machine .
18+ // OverlayMount holds the state for a single overlay filesystem setup .
1619type OverlayMount struct {
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
20+ SessionID string
21+ SourceDir string // sshfs mount of local workspace (lower)
22+ CacheDir string // mutagen-warmed cache (upper)
23+ WorkDir string // overlayfs workdir
24+ MergedDir string // final merged view
25+ SshfsCmdID string // process manager ID for sshfs
26+ OverlayCmdID string // process manager ID for fuse-overlayfs
2127}
2228
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.
26- func SetupOverlay (sessionID string , localSshPort int , localWorkspace string ) (* OverlayMount , error ) {
29+ // Static binary download URLs keyed by GOOS/GOARCH.
30+ var fuseToolURLs = map [string ]struct { sshfs , overlayfs string }{
31+ "linux/amd64" : {
32+ sshfs : "https://github.com/cyber-shuttle/linkspan/releases/download/fuse-tools-v1/sshfs-linux-amd64" ,
33+ overlayfs : "https://github.com/cyber-shuttle/linkspan/releases/download/fuse-tools-v1/fuse-overlayfs-linux-amd64" ,
34+ },
35+ "linux/arm64" : {
36+ sshfs : "https://github.com/cyber-shuttle/linkspan/releases/download/fuse-tools-v1/sshfs-linux-arm64" ,
37+ overlayfs : "https://github.com/cyber-shuttle/linkspan/releases/download/fuse-tools-v1/fuse-overlayfs-linux-arm64" ,
38+ },
39+ }
40+
41+ var fuseToolsMu sync.Mutex
42+
43+ // ensureFuseTool downloads a FUSE tool binary to ~/.linkspan/bin/ if not
44+ // already present. Returns the absolute path to the binary.
45+ func ensureFuseTool (name , url string ) (string , error ) {
2746 home , err := os .UserHomeDir ()
2847 if err != nil {
29- return nil , fmt .Errorf ("overlay: get home dir: %w" , err )
48+ return "" , fmt .Errorf ("resolve home dir: %w" , err )
3049 }
3150
32- m := & OverlayMount {
33- SessionID : sessionID ,
34- SourceDir : localWorkspace ,
35- CacheDir : filepath .Join (home , "sessions" , sessionID ),
36- MergedDir : filepath .Join (home , "overlay" , sessionID ),
37- }
51+ binDir := filepath .Join (home , ".linkspan" , "bin" )
52+ binPath := filepath .Join (binDir , name )
3853
39- if err := os .MkdirAll ( m . MergedDir , 0755 ); err ! = nil {
40- return nil , fmt . Errorf ( "overlay: mkdir %s: %w" , m . MergedDir , err )
54+ if _ , err := os .Stat ( binPath ); err = = nil {
55+ return binPath , nil
4156 }
4257
43- log .Printf ("[overlay] syncing %s from localhost:%d → %s" , localWorkspace , localSshPort , m .MergedDir )
58+ fuseToolsMu .Lock ()
59+ defer fuseToolsMu .Unlock ()
4460
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
61+ // Re-check after acquiring lock
62+ if _ , err := os .Stat (binPath ); err == nil {
63+ return binPath , nil
5164 }
5265
53- addr := fmt .Sprintf ("127.0.0.1:%d" , localSshPort )
54- conn , err := ssh .Dial ("tcp" , addr , sshConfig )
55- if err != nil {
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- }
66+ log .Printf ("[overlay] downloading %s from %s" , name , url )
67+ if err := os .MkdirAll (binDir , 0o755 ); err != nil {
68+ return "" , fmt .Errorf ("create bin dir: %w" , err )
6269 }
63- defer conn .Close ()
6470
65- client , err := sftp .NewClient (conn )
71+ //nolint:gosec,noctx
72+ resp , err := http .Get (url )
6673 if err != nil {
67- return nil , fmt .Errorf ("overlay: sftp client: %w" , err )
74+ return "" , fmt .Errorf ("download %s: %w" , name , err )
75+ }
76+ defer resp .Body .Close ()
77+ if resp .StatusCode != http .StatusOK {
78+ return "" , fmt .Errorf ("download %s: HTTP %s" , name , resp .Status )
6879 }
69- defer client .Close ()
7080
71- // Recursive sync from remote (local machine) to local (compute node)
72- copied , err := syncDir (client , localWorkspace , m .MergedDir )
81+ tmp , err := os .CreateTemp (binDir , "." + name + "-*" )
7382 if err != nil {
74- return nil , fmt . Errorf ( "overlay: sync: %w " , err )
83+ return " " , err
7584 }
85+ tmpPath := tmp .Name ()
86+ defer func () {
87+ tmp .Close ()
88+ if _ , e := os .Stat (tmpPath ); e == nil {
89+ _ = os .Remove (tmpPath )
90+ }
91+ }()
7692
77- log .Printf ("[overlay] synced %d files to %s" , copied , m .MergedDir )
78- return m , nil
93+ if _ , err := io .Copy (tmp , resp .Body ); err != nil {
94+ return "" , fmt .Errorf ("write %s: %w" , name , err )
95+ }
96+ if err := tmp .Close (); err != nil {
97+ return "" , err
98+ }
99+ if err := os .Chmod (tmpPath , 0o755 ); err != nil {
100+ return "" , err
101+ }
102+ if err := os .Rename (tmpPath , binPath ); err != nil {
103+ return "" , fmt .Errorf ("install %s: %w" , name , err )
104+ }
105+
106+ log .Printf ("[overlay] %s ready at %s" , name , binPath )
107+ return binPath , nil
108+ }
109+
110+ // resolveBin returns the path to a binary, checking system PATH first and
111+ // falling back to a managed download.
112+ func resolveBin (name string , downloadURL string ) (string , error ) {
113+ if p , err := exec .LookPath (name ); err == nil {
114+ return p , nil
115+ }
116+ if downloadURL == "" {
117+ return "" , fmt .Errorf ("%s not found in PATH and no download URL for %s/%s" , name , runtime .GOOS , runtime .GOARCH )
118+ }
119+ return ensureFuseTool (name , downloadURL )
79120}
80121
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 )
122+ // SetupOverlay creates the overlay filesystem:
123+ // 1. sshfs mounts the local workspace via tunnel
124+ // 2. fuse-overlayfs merges lower (sshfs) + upper (cache)
125+ func SetupOverlay (sessionID string , localSshPort int , localWorkspace string ) (* OverlayMount , error ) {
126+ home , err := os .UserHomeDir ()
84127 if err != nil {
85- return 0 , fmt .Errorf ("readdir %s : %w" , remotePath , err )
128+ return nil , fmt .Errorf ("overlay: get home dir : %w" , err )
86129 }
87130
88- count := 0
89- for _ , entry := range entries {
90- name := entry .Name ()
131+ key := runtime .GOOS + "/" + runtime .GOARCH
132+ urls := fuseToolURLs [key ] // zero value if not found
91133
92- // Skip common non-essential dirs to speed up initial sync
93- if entry . IsDir () && shouldSkipDir ( name ) {
94- continue
95- }
134+ sshfsBin , err := resolveBin ( "sshfs" , urls . sshfs )
135+ if err != nil {
136+ return nil , fmt . Errorf ( "overlay: %w" , err )
137+ }
96138
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
139+ overlayBin , err := resolveBin ("fuse-overlayfs" , urls .overlayfs )
140+ if err != nil {
141+ return nil , fmt .Errorf ("overlay: %w" , err )
117142 }
118- return count , nil
119- }
120143
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" ,
144+ m := & OverlayMount {
145+ SessionID : sessionID ,
146+ SourceDir : filepath . Join ( os . TempDir (), fmt . Sprintf ( "cs-source-%s" , sessionID )),
147+ CacheDir : filepath . Join ( home , "sessions " , sessionID ) ,
148+ WorkDir : filepath . Join ( home , "sessions " , sessionID , ".overlay-work" ) ,
149+ MergedDir : filepath . Join ( home , "overlay " , sessionID ) ,
127150 }
128- for _ , s := range skip {
129- if strings .EqualFold (name , s ) {
130- return true
151+
152+ // Create all directories
153+ for _ , dir := range []string {m .SourceDir , m .CacheDir , m .WorkDir , m .MergedDir } {
154+ if err := os .MkdirAll (dir , 0755 ); err != nil {
155+ return nil , fmt .Errorf ("overlay: mkdir %s: %w" , dir , err )
131156 }
132157 }
133- return false
134- }
135158
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- }
159+ // 1. sshfs mount local workspace via tunnel
160+ sshfsArgs := []string {
161+ fmt .Sprintf ("localhost:%s" , localWorkspace ),
162+ m .SourceDir ,
163+ "-f" , // foreground — required for process manager tracking
164+ "-p" , fmt .Sprintf ("%d" , localSshPort ),
165+ "-o" , "StrictHostKeyChecking=no" ,
166+ "-o" , "UserKnownHostsFile=/dev/null" ,
167+ "-o" , "reconnect" ,
168+ "-o" , "ServerAliveInterval=15" ,
169+ "-o" , "ServerAliveCountMax=3" ,
144170 }
145171
146- src , err := client .Open (remotePath )
172+ sshfsCmd := exec .Command (sshfsBin , sshfsArgs ... )
173+ sshfsCmdID , err := pm .GlobalProcessManager .Start (sshfsCmd )
147174 if err != nil {
148- return err
175+ return nil , fmt .Errorf ("overlay: start sshfs: %w" , err )
176+ }
177+ m .SshfsCmdID = sshfsCmdID
178+
179+ // Brief pause to let sshfs establish the mount
180+ time .Sleep (2 * time .Second )
181+ log .Printf ("[overlay] sshfs mounted %s on port %d → %s" , localWorkspace , localSshPort , m .SourceDir )
182+
183+ // 2. fuse-overlayfs
184+ overlayArgs := []string {
185+ "-o" , fmt .Sprintf ("lowerdir=%s,upperdir=%s,workdir=%s" , m .SourceDir , m .CacheDir , m .WorkDir ),
186+ m .MergedDir ,
149187 }
150- defer src .Close ()
151188
152- dst , err := os .OpenFile (localPath , os .O_CREATE | os .O_WRONLY | os .O_TRUNC , mode )
189+ overlayCmd := exec .Command (overlayBin , overlayArgs ... )
190+ overlayCmdID , err := pm .GlobalProcessManager .Start (overlayCmd )
153191 if err != nil {
154- return err
192+ _ = pm .GlobalProcessManager .Kill (sshfsCmdID )
193+ return nil , fmt .Errorf ("overlay: start fuse-overlayfs: %w" , err )
155194 }
156- defer dst .Close ()
195+ m .OverlayCmdID = overlayCmdID
196+ log .Printf ("[overlay] fuse-overlayfs merged at %s (lower=%s, upper=%s)" , m .MergedDir , m .SourceDir , m .CacheDir )
157197
158- _ , err = io .Copy (dst , src )
159- return err
198+ return m , nil
160199}
161200
162- // Teardown removes the synced directory .
201+ // Teardown unmounts the overlay and sshfs .
163202func (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- }
203+ if m .OverlayCmdID != "" {
204+ _ = pm .GlobalProcessManager .Kill (m .OverlayCmdID )
205+ log .Printf ("[overlay] stopped fuse-overlayfs for %s" , m .SessionID )
206+ }
207+ _ = exec .Command ("fusermount" , "-u" , m .MergedDir ).Run ()
208+ _ = exec .Command ("fusermount" , "-u" , m .SourceDir ).Run ()
167209
210+ if m .SshfsCmdID != "" {
211+ _ = pm .GlobalProcessManager .Kill (m .SshfsCmdID )
212+ log .Printf ("[overlay] stopped sshfs for %s" , m .SessionID )
213+ }
214+ }
0 commit comments