Skip to content

Commit 0e0493e

Browse files
yasithdevclaude
andcommitted
Restore sshfs + fuse-overlayfs overlay with auto-download
Reverts to the FUSE-based overlay approach but auto-downloads static sshfs and fuse-overlayfs binaries to ~/.linkspan/bin/ when not found on PATH. Falls back to system binaries if available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4411d7b commit 0e0493e

File tree

1 file changed

+157
-110
lines changed

1 file changed

+157
-110
lines changed

subsystems/mount/overlay.go

Lines changed: 157 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
1619
type 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.
163202
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-
}
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

Comments
 (0)