Skip to content

Commit 3dedba4

Browse files
authored
[devbox] Add client code for devbox cloud (#310)
## Summary Adds client code to the devbox cli for `devbox cloud shell`. The command is still hidden so we can further test and refine the feature before we decide to turn it on. There's no new logic in this PR, only moving files around. ## How was it tested? `go run cmd/devbox/main.go cloud shell`
1 parent 299ae9e commit 3dedba4

File tree

18 files changed

+1007
-0
lines changed

18 files changed

+1007
-0
lines changed

boxcli/cloud.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package boxcli
5+
6+
import (
7+
"github.com/spf13/cobra"
8+
"go.jetpack.io/devbox/cloud"
9+
)
10+
11+
func CloudCmd() *cobra.Command {
12+
command := &cobra.Command{
13+
Use: "cloud",
14+
Short: "Remote development environments on the cloud",
15+
Hidden: true,
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
return cmd.Help()
18+
},
19+
}
20+
command.AddCommand(cloudShellCmd())
21+
return command
22+
}
23+
24+
func cloudShellCmd() *cobra.Command {
25+
command := &cobra.Command{
26+
Use: "shell",
27+
Short: "Shell into a cloud environment that matches your local devbox environment",
28+
RunE: runCloudShellCmd,
29+
}
30+
31+
return command
32+
}
33+
34+
func runCloudShellCmd(cmd *cobra.Command, args []string) error {
35+
return cloud.Shell()
36+
}

boxcli/doc-gen.go renamed to boxcli/gen-docs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
14
package boxcli
25

36
import (

boxcli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func RootCmd() *cobra.Command {
2727
}
2828
command.AddCommand(AddCmd())
2929
command.AddCommand(BuildCmd())
30+
command.AddCommand(CloudCmd())
3031
command.AddCommand(GenerateCmd())
3132
command.AddCommand(InfoCmd())
3233
command.AddCommand(InitCmd())

boxcli/shell_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
22
// Use of this source code is governed by the license in the LICENSE file.
3+
34
package boxcli
45

56
import (

cloud/cloud.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package cloud
5+
6+
import (
7+
"encoding/json"
8+
"fmt"
9+
"log"
10+
"os"
11+
"strings"
12+
"time"
13+
14+
"github.com/AlecAivazis/survey/v2"
15+
"github.com/fatih/color"
16+
"go.jetpack.io/devbox/cloud/mutagen"
17+
"go.jetpack.io/devbox/cloud/sshclient"
18+
"go.jetpack.io/devbox/cloud/sshconfig"
19+
"go.jetpack.io/devbox/cloud/stepper"
20+
)
21+
22+
func Shell() error {
23+
// TODO: check if `devbox.json` exists.
24+
// TODO: find project's "root" directory based on `devbox.json` location.
25+
setupSSHConfig()
26+
27+
c := color.New(color.FgMagenta).Add(color.Bold)
28+
c.Println("Devbox Cloud")
29+
fmt.Println("Blazingly fast remote development that feels local")
30+
fmt.Print("\n")
31+
32+
username := promptUsername()
33+
s1 := stepper.Start("Creating a virtual machine on the cloud...")
34+
vmHostname := getVirtualMachine(username)
35+
s1.Success("Created virtual machine")
36+
37+
s2 := stepper.Start("Starting file syncing...")
38+
err := syncFiles(username, vmHostname)
39+
if err != nil {
40+
s2.Fail("Starting file syncing [FAILED]")
41+
log.Fatal(err)
42+
}
43+
s2.Success("File syncing started")
44+
45+
s3 := stepper.Start("Connecting to virtual machine...")
46+
time.Sleep(1 * time.Second)
47+
s3.Stop("Connecting to virtual machine")
48+
49+
fmt.Print("\n")
50+
51+
return shell(username, vmHostname)
52+
}
53+
54+
func setupSSHConfig() {
55+
if err := sshconfig.Setup(); err != nil {
56+
log.Fatal(err)
57+
}
58+
}
59+
60+
func promptUsername() string {
61+
username := ""
62+
prompt := &survey.Input{
63+
Message: "What is your github username?",
64+
Default: os.Getenv("USER"),
65+
}
66+
err := survey.AskOne(prompt, &username, survey.WithValidator(survey.Required))
67+
if err != nil {
68+
log.Fatal(err)
69+
}
70+
return username
71+
}
72+
73+
type authResponse struct {
74+
VMHostname string `json:"vm_host"`
75+
}
76+
77+
func getVirtualMachine(username string) string {
78+
client := sshclient.Client{
79+
Username: username,
80+
// TODO: change gateway to prod by default before relesing.
81+
Hostname: "gateway.dev.devbox.sh",
82+
}
83+
bytes, err := client.Exec("auth")
84+
if err != nil {
85+
log.Fatal(err)
86+
}
87+
resp := &authResponse{}
88+
err = json.Unmarshal(bytes, resp)
89+
if err != nil {
90+
log.Fatal(err)
91+
}
92+
93+
return resp.VMHostname
94+
}
95+
96+
func syncFiles(username string, hostname string) error {
97+
// TODO: instead of id, have the server return the machine's name and use that
98+
// here to. It'll make things easier to debug.
99+
id, _, _ := strings.Cut(hostname, ".")
100+
_, err := mutagen.Sync(&mutagen.SessionSpec{
101+
// If multiple projects can sync to the same machine, we need the name to also include
102+
// the project's id.
103+
Name: fmt.Sprintf("devbox-%s", id),
104+
AlphaPath: ".", // We should use location of `devbox.json`
105+
BetaAddress: fmt.Sprintf("%s@%s", username, hostname),
106+
// It's important that the beta path is a "clean" directory that will contain *only*
107+
// the projects files. If we pick a pre-existing directories with other files, those
108+
// files will be synced back to the local directory (due to two-way-sync) and pollute
109+
// the user's local project
110+
BetaPath: "~/Code/",
111+
IgnoreVCS: true,
112+
SyncMode: "two-way-resolved",
113+
})
114+
if err != nil {
115+
return err
116+
}
117+
time.Sleep(1 * time.Second)
118+
return nil
119+
}
120+
121+
func shell(username string, hostname string) error {
122+
client := &sshclient.Client{
123+
Username: username,
124+
Hostname: hostname,
125+
}
126+
return client.Shell()
127+
}

cloud/mutagen/fileutil.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package mutagen
2+
3+
import (
4+
"os"
5+
)
6+
7+
// TODO: publish as it's own shared package that other binaries
8+
// can use.
9+
10+
// IsDir returns true if the path exists *and* it is pointing to a directory.
11+
//
12+
// This function will traverse symbolic links to query information about the
13+
// destination file.
14+
//
15+
// This is a convenience function that coerces errors to false. If it cannot
16+
// read the path for any reason (including a permission error, or a broken
17+
// symbolic link) it returns false.
18+
func IsDir(path string) bool {
19+
info, err := os.Stat(path)
20+
if err != nil {
21+
return false
22+
}
23+
return info.IsDir()
24+
}
25+
26+
// IsFile returns true if the path exists *and* it is pointing to a regular file.
27+
//
28+
// This function will traverse symbolic links to query information about the
29+
// destination file.
30+
//
31+
// This is a convenience function that coerces errors to false. If it cannot
32+
// read the path for any reason (including a permission error, or a broken
33+
// symbolic link) it returns false.
34+
func IsFile(path string) bool {
35+
info, err := os.Stat(path)
36+
if err != nil {
37+
return false
38+
}
39+
return info.Mode().IsRegular()
40+
}
41+
42+
// IsSymlink returns true if the path exists *and* it is a symlink.
43+
//
44+
// It does *not* traverse symbolic links, and returns true even if the symlink
45+
// is broken.
46+
//
47+
// This is a convenience function that coerces errors to false. If it cannot
48+
// read the path for any reason (including a permission error) it returns false.
49+
func IsSymlink(path string) bool {
50+
info, err := os.Lstat(path)
51+
if err != nil {
52+
return false
53+
}
54+
return (info.Mode().Type() & os.ModeSymlink) == os.ModeSymlink
55+
}
56+
57+
func ExistsOrErr(path string) error {
58+
_, err := os.Stat(path)
59+
return err
60+
}

cloud/mutagen/install.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package mutagen
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
9+
"github.com/cavaliergopher/grab/v3"
10+
)
11+
12+
func InstallMutagenOnce(binPath string) error {
13+
if IsFile(binPath) {
14+
// Already installed, do nothing
15+
// TODO: ideally we would check that the right version
16+
// is installed, and maybe we should also validate
17+
// with a checksum.
18+
return nil
19+
}
20+
21+
url := mutagenURL()
22+
installDir := filepath.Dir(binPath)
23+
24+
return Install(url, installDir)
25+
}
26+
27+
func Install(url string, installDir string) error {
28+
err := os.MkdirAll(installDir, 0755)
29+
if err != nil {
30+
return err
31+
}
32+
33+
// TODO: add checksum validation
34+
resp, err := grab.Get(os.TempDir(), url)
35+
if err != nil {
36+
return err
37+
}
38+
39+
tarPath := resp.Filename
40+
tarReader, err := os.Open(tarPath)
41+
if err != nil {
42+
return err
43+
}
44+
err = Untar(tarReader, installDir)
45+
if err != nil {
46+
return err
47+
}
48+
return nil
49+
}
50+
51+
func mutagenURL() string {
52+
repo := "mutagen-io/mutagen"
53+
pkg := "mutagen"
54+
version := "v0.16.1" // Hard-coded for now, but change to always get the latest?
55+
platform := detectPlatform()
56+
57+
return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s_%s_%s.tar.gz", repo, version, pkg, platform, version)
58+
}
59+
60+
func detectOS() string {
61+
return runtime.GOOS
62+
}
63+
64+
func detectArch() string {
65+
return runtime.GOARCH
66+
}
67+
68+
func detectPlatform() string {
69+
os := detectOS()
70+
arch := detectArch()
71+
return fmt.Sprintf("%s_%s", os, arch)
72+
}

cloud/mutagen/sync.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved.
2+
// Use of this source code is governed by the license in the LICENSE file.
3+
4+
package mutagen
5+
6+
import (
7+
"errors"
8+
)
9+
10+
func Sync(spec *SessionSpec) (*Session, error) {
11+
if spec.Name == "" {
12+
return nil, errors.New("name is required")
13+
}
14+
15+
// Check if there's an existing sessions or not
16+
sessions, err := List(spec.Name)
17+
if err != nil {
18+
return nil, err
19+
}
20+
21+
// If there isn't, create a new one
22+
if len(sessions) == 0 {
23+
err = Create(spec)
24+
if err != nil {
25+
return nil, err
26+
}
27+
}
28+
// Whether new or pre-existing, find the sessions object, ensure
29+
// that it's not paused, and return it.
30+
sessions, err = List(spec.Name)
31+
if err != nil {
32+
return nil, err
33+
}
34+
for _, session := range sessions {
35+
// TODO: should we handle errors for Reset and Resume differently?
36+
_ = Reset(session.Identifier)
37+
_ = Resume(session.Identifier)
38+
}
39+
if len(sessions) > 0 {
40+
return &sessions[0], nil
41+
} else {
42+
return nil, errors.New("failed to find session that was just created")
43+
}
44+
// TODO: starting the mutagen session currently fails if there's any error or
45+
// interactivity required for the ssh connection.
46+
// That includes:
47+
// - When connecting for the first time and adding the host to known_hosts
48+
// - When the key has changed and SSH warns of a man-in-the-middle attack
49+
}

0 commit comments

Comments
 (0)