Skip to content

Commit 09b7200

Browse files
authored
[cloud] emulate user's local directory structure in VM (#440)
## Summary **Problem** A scenario we want to protect against is two distinct projects sharing the same directory name. For example, consider projects located at `~/customer1/my_project` and `~/customer2/my_project`. Prior to this PR, we would attempt to sync both projects to the same folder in the VM: `~/code/my_project`. **Approach** This PR attempts to solve this conflict by seeking to emulate the user's directory structure in the VM. So, for the aforementioned example projects, they will sync to `~/customer1/my_project` and `~/customer2/my_project` in the VM as well. This has the additional benefit that if a user has relative paths between two projects they are actively developing (for example, a golang library and golang application that uses that library), then these relative paths will continue working in the cloud-shell, in accordance with our desires to make the cloud-shell experience akin to local shells. **Edge case: projects outside homedir** An edge case is for projects that are located locally outside the user's homedir. For security reasons, in the VM, we do not want to give the user root access, or access to folders outside their homedir. We choose to sync to a special folder called: `~/outside-homedir-code/<absolute path>`. For example, a project located locally at `/my_company/my_project` will sync to `~/outside-homedir-code/my_company/my_project`. There are two downsides to this which are tolerable IMO: 1. If a user _also has_ a local folder called `~/devbox-cloud` and they want to sync those projects then there will be a conflict. 2. Relative paths between a project in homedir and outside the homedir will not work. The user can inspect the paths in the cloud-shells via `pwd` and update the relative paths to match that. ## How was it tested? for `testdata/go/go-1.19`: could sync to VM via `devbox cloud shell`, and inspect directory in VM with `pwd`. for edge case: projects outside homedir: 1. copied `examples/testdata/rust/rust-stable` to `/usr/local/testy-nonhome-dir/rust-stable` 2. fixed permissions: `sudo chown savil:staff /usr/local/testy-nonhome-dir/rust-stable` 3. `devbox cloud shell` to VM 4. `pwd` in VM gave: `/home/savil/outside-homedir-code/usr/local/testy-nonhome-dir/rust-stable` 5. `devbox add gcc` and then `cargo run` ran successfully.
1 parent c385c09 commit 09b7200

File tree

3 files changed

+99
-37
lines changed

3 files changed

+99
-37
lines changed

internal/cloud/cloud.go

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,14 @@ func getVirtualMachine(client openssh.Client) (vmHost string, region string) {
163163

164164
func syncFiles(username, hostname, projectDir string) error {
165165

166-
projectName := projectDirName(projectDir)
167-
debug.Log("Will sync files to directory: ~/code/%s", projectName)
166+
relProjectPathInVM, err := relativeProjectPathInVM(projectDir)
167+
if err != nil {
168+
return err
169+
}
170+
absPathInVM := absoluteProjectPathInVM(username, relProjectPathInVM)
171+
debug.Log("absPathInVM: %s", absPathInVM)
168172

169-
err := copyConfigFileToVM(hostname, username, projectDir, projectName)
173+
err = copyConfigFileToVM(hostname, username, projectDir, absPathInVM)
170174
if err != nil {
171175
return err
172176
}
@@ -184,7 +188,9 @@ func syncFiles(username, hostname, projectDir string) error {
184188
// TODO: instead of id, have the server return the machine's name and use that
185189
// here to. It'll make things easier to debug.
186190
machineID, _, _ := strings.Cut(hostname, ".")
187-
mutagenSessionName := mutagen.SanitizeSessionName(fmt.Sprintf("devbox-%s-%s", projectName, machineID))
191+
mutagenSessionName := mutagen.SanitizeSessionName(fmt.Sprintf("devbox-%s-%s", machineID,
192+
hyphenatePath(relProjectPathInVM)))
193+
188194
_, err = mutagen.Sync(&mutagen.SessionSpec{
189195
// If multiple projects can sync to the same machine, we need the name to also include
190196
// the project's id.
@@ -195,7 +201,7 @@ func syncFiles(username, hostname, projectDir string) error {
195201
// the projects files. If we pick a pre-existing directories with other files, those
196202
// files will be synced back to the local directory (due to two-way-sync) and pollute
197203
// the user's local project
198-
BetaPath: projectPathInVM(projectName),
204+
BetaPath: absPathInVM,
199205
EnvVars: env,
200206
Ignore: mutagen.SessionIgnore{
201207
VCS: true,
@@ -210,20 +216,21 @@ func syncFiles(username, hostname, projectDir string) error {
210216
time.Sleep(1 * time.Second)
211217

212218
// In a background routine, update the sync status in the cloud VM
213-
go updateSyncStatus(mutagenSessionName, username, hostname, projectName)
219+
go updateSyncStatus(mutagenSessionName, username, hostname, relProjectPathInVM)
214220
return nil
215221
}
216222

217223
// updateSyncStatus updates the starship prompt.
218224
//
219225
// wait for the mutagen session's status to change to "watching", and update the remote VM
220226
// when the initial project sync completes and then exit.
221-
func updateSyncStatus(mutagenSessionName, username, hostname, projectName string) {
227+
func updateSyncStatus(mutagenSessionName, username, hostname, relProjectPathInVM string) {
228+
222229
status := "disconnected"
223230

224231
// Ensure the destination directory exists
225232
destServer := fmt.Sprintf("%s@%s", username, hostname)
226-
destDir := fmt.Sprintf("/home/%s/.config/devbox/starship/%s", username, projectName)
233+
destDir := fmt.Sprintf("/home/%s/.config/devbox/starship/%s", username, hyphenatePath(filepath.Base(relProjectPathInVM)))
227234
remoteCmd := fmt.Sprintf("mkdir -p %s", destDir)
228235
cmd := exec.Command("ssh", destServer, remoteCmd)
229236
err := cmd.Run()
@@ -274,11 +281,11 @@ func getSyncStatus(mutagenSessionName string) (string, error) {
274281
return sessions[0].Status, nil
275282
}
276283

277-
func copyConfigFileToVM(hostname, username, projectDir, projectName string) error {
284+
func copyConfigFileToVM(hostname, username, projectDir, pathInVM string) error {
278285

279286
// Ensure the devbox-project's directory exists in the VM
280287
destServer := fmt.Sprintf("%s@%s", username, hostname)
281-
cmd := exec.Command("ssh", destServer, "--", "mkdir", "-p", projectPathInVM(projectName))
288+
cmd := exec.Command("ssh", destServer, "--", "mkdir", "-p", pathInVM)
282289
err := cmd.Run()
283290
debug.Log("ssh mkdir command: %s with error: %s", cmd, err)
284291
if err != nil {
@@ -287,36 +294,68 @@ func copyConfigFileToVM(hostname, username, projectDir, projectName string) erro
287294

288295
// Copy the config file to the devbox-project directory in the VM
289296
configFilePath := filepath.Join(projectDir, "devbox.json")
290-
destPath := fmt.Sprintf("%s:%s", destServer, projectPathInVM(projectName))
297+
destPath := fmt.Sprintf("%s:%s", destServer, pathInVM)
291298
cmd = exec.Command("scp", configFilePath, destPath)
292299
err = cmd.Run()
293300
debug.Log("scp devbox.json command: %s with error: %s", cmd, err)
294301
return errors.WithStack(err)
295302
}
296303

297-
func projectPathInVM(projectName string) string {
298-
return fmt.Sprintf("~/code/%s/", projectName)
299-
}
300-
301304
func shell(username, hostname, projectDir string) error {
305+
projectPath, err := relativeProjectPathInVM(projectDir)
306+
if err != nil {
307+
return err
308+
}
309+
302310
client := &openssh.Client{
303-
Username: username,
304-
Addr: hostname,
305-
ProjectDirName: projectDirName(projectDir),
311+
Username: username,
312+
Addr: hostname,
313+
PathInVM: absoluteProjectPathInVM(username, projectPath),
306314
}
307315
return client.Shell()
308316
}
309317

310-
const defaultProjectDirName = "devbox_project"
311-
318+
// relativeProjectPathInVM refers to the project path relative to the user's
319+
// home-directory within the VM.
320+
//
312321
// Ideally, we'd pass in devbox.Devbox struct and call ProjectDir but it
313322
// makes it hard to wrap this in a test
314-
func projectDirName(projectDir string) string {
315-
name := filepath.Base(projectDir)
316-
if name == "/" || name == "." {
317-
return defaultProjectDirName
323+
func relativeProjectPathInVM(projectDir string) (string, error) {
324+
home, err := os.UserHomeDir()
325+
if err != nil {
326+
return "", errors.WithStack(err)
327+
}
328+
329+
// get absProjectDir to expand "." and so on
330+
absProjectDir, err := filepath.Abs(projectDir)
331+
if err != nil {
332+
return "", errors.WithStack(err)
318333
}
319-
return name
334+
projectDir = filepath.Clean(absProjectDir)
335+
336+
if !strings.HasPrefix(projectDir, home) {
337+
projectDir, err = filepath.Abs(projectDir)
338+
if err != nil {
339+
return "", errors.WithStack(err)
340+
}
341+
return filepath.Join(outsideHomedirDirectory, projectDir), nil
342+
}
343+
344+
relativeProjectDir, err := filepath.Rel(home, projectDir)
345+
if err != nil {
346+
return "", errors.WithStack(err)
347+
}
348+
return relativeProjectDir, nil
349+
}
350+
351+
const outsideHomedirDirectory = "outside-homedir-code"
352+
353+
func absoluteProjectPathInVM(sshUser, relativeProjectPath string) string {
354+
vmHomeDir := fmt.Sprintf("/home/%s", sshUser)
355+
if strings.HasPrefix(relativeProjectPath, outsideHomedirDirectory) {
356+
return fmt.Sprintf("%s/%s", vmHomeDir, relativeProjectPath)
357+
}
358+
return fmt.Sprintf("%s/%s/", vmHomeDir, relativeProjectPath)
320359
}
321360

322361
func parseVMEnvVar() (username string, vmHostname string) {
@@ -385,3 +424,7 @@ func vmHostnameFromSSHControlPath() string {
385424
// empty string means that aren't any active VM connections
386425
return ""
387426
}
427+
428+
func hyphenatePath(path string) string {
429+
return strings.ReplaceAll(path, "/", "-")
430+
}

internal/cloud/cloud_test.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,48 @@
11
package cloud
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
79
)
810

911
func TestProjectDirName(t *testing.T) {
12+
assertion := assert.New(t)
13+
14+
homeDir, err := os.UserHomeDir()
15+
assertion.NoError(err)
16+
17+
workingDir, err := os.Getwd()
18+
assertion.NoError(err)
19+
20+
relWorkingDir, err := filepath.Rel(homeDir, workingDir)
21+
assertion.NoError(err)
1022

1123
testCases := []struct {
1224
projectDir string
13-
dirName string
25+
dirPath string
1426
}{
15-
{"/", defaultProjectDirName},
16-
{".", defaultProjectDirName},
17-
{"/foo", "foo"},
18-
{"foo/bar", "bar"},
19-
{"foo/bar/", "bar"},
20-
{"foo/bar///", "bar"},
27+
// inside homedir
28+
{".", relWorkingDir},
29+
{filepath.Join(homeDir, "foo"), "foo"},
30+
{filepath.Join(homeDir, "foo/bar"), "foo/bar"},
31+
32+
// non-home-dir
33+
{"/", filepath.Join(outsideHomedirDirectory, "/")},
34+
{"/foo", filepath.Join(outsideHomedirDirectory, "/foo")},
35+
{"/foo/bar", filepath.Join(outsideHomedirDirectory, "/foo/bar")},
36+
{"/foo/bar/", filepath.Join(outsideHomedirDirectory, "/foo/bar")},
37+
{"/foo/bar///", filepath.Join(outsideHomedirDirectory, "/foo/bar")},
2138
}
2239

2340
for _, testCase := range testCases {
2441
t.Run(testCase.projectDir, func(t *testing.T) {
2542
assert := assert.New(t)
26-
assert.Equal(testCase.dirName, projectDirName(testCase.projectDir))
43+
path, err := relativeProjectPathInVM(testCase.projectDir)
44+
assert.NoError(err)
45+
assert.Equal(testCase.dirPath, path)
2746
})
2847
}
2948
}

internal/cloud/openssh/client.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ import (
1717
)
1818

1919
type Client struct {
20-
Username string
21-
Addr string
22-
ProjectDirName string
20+
Username string
21+
Addr string
22+
PathInVM string
2323
}
2424

2525
func (c *Client) Shell() error {
2626
cmd := c.cmd("-t")
27-
remoteCmd := fmt.Sprintf(`bash -l -c "start_devbox_shell.sh \"%s\""`, c.ProjectDirName)
27+
remoteCmd := fmt.Sprintf(`bash -l -c "start_devbox_shell.sh \"%s\""`, c.PathInVM)
2828
cmd.Args = append(cmd.Args, remoteCmd)
2929
cmd.Stdin = os.Stdin
3030
cmd.Stdout = os.Stdout

0 commit comments

Comments
 (0)