-
Notifications
You must be signed in to change notification settings - Fork 218
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[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`
- Loading branch information
Showing
18 changed files
with
1,007 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved. | ||
// Use of this source code is governed by the license in the LICENSE file. | ||
|
||
package boxcli | ||
|
||
import ( | ||
"github.com/spf13/cobra" | ||
"go.jetpack.io/devbox/cloud" | ||
) | ||
|
||
func CloudCmd() *cobra.Command { | ||
command := &cobra.Command{ | ||
Use: "cloud", | ||
Short: "Remote development environments on the cloud", | ||
Hidden: true, | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
return cmd.Help() | ||
}, | ||
} | ||
command.AddCommand(cloudShellCmd()) | ||
return command | ||
} | ||
|
||
func cloudShellCmd() *cobra.Command { | ||
command := &cobra.Command{ | ||
Use: "shell", | ||
Short: "Shell into a cloud environment that matches your local devbox environment", | ||
RunE: runCloudShellCmd, | ||
} | ||
|
||
return command | ||
} | ||
|
||
func runCloudShellCmd(cmd *cobra.Command, args []string) error { | ||
return cloud.Shell() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved. | ||
// Use of this source code is governed by the license in the LICENSE file. | ||
|
||
package cloud | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"log" | ||
"os" | ||
"strings" | ||
"time" | ||
|
||
"github.com/AlecAivazis/survey/v2" | ||
"github.com/fatih/color" | ||
"go.jetpack.io/devbox/cloud/mutagen" | ||
"go.jetpack.io/devbox/cloud/sshclient" | ||
"go.jetpack.io/devbox/cloud/sshconfig" | ||
"go.jetpack.io/devbox/cloud/stepper" | ||
) | ||
|
||
func Shell() error { | ||
// TODO: check if `devbox.json` exists. | ||
// TODO: find project's "root" directory based on `devbox.json` location. | ||
setupSSHConfig() | ||
|
||
c := color.New(color.FgMagenta).Add(color.Bold) | ||
c.Println("Devbox Cloud") | ||
fmt.Println("Blazingly fast remote development that feels local") | ||
fmt.Print("\n") | ||
|
||
username := promptUsername() | ||
s1 := stepper.Start("Creating a virtual machine on the cloud...") | ||
vmHostname := getVirtualMachine(username) | ||
s1.Success("Created virtual machine") | ||
|
||
s2 := stepper.Start("Starting file syncing...") | ||
err := syncFiles(username, vmHostname) | ||
if err != nil { | ||
s2.Fail("Starting file syncing [FAILED]") | ||
log.Fatal(err) | ||
} | ||
s2.Success("File syncing started") | ||
|
||
s3 := stepper.Start("Connecting to virtual machine...") | ||
time.Sleep(1 * time.Second) | ||
s3.Stop("Connecting to virtual machine") | ||
|
||
fmt.Print("\n") | ||
|
||
return shell(username, vmHostname) | ||
} | ||
|
||
func setupSSHConfig() { | ||
if err := sshconfig.Setup(); err != nil { | ||
log.Fatal(err) | ||
} | ||
} | ||
|
||
func promptUsername() string { | ||
username := "" | ||
prompt := &survey.Input{ | ||
Message: "What is your github username?", | ||
Default: os.Getenv("USER"), | ||
} | ||
err := survey.AskOne(prompt, &username, survey.WithValidator(survey.Required)) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
return username | ||
} | ||
|
||
type authResponse struct { | ||
VMHostname string `json:"vm_host"` | ||
} | ||
|
||
func getVirtualMachine(username string) string { | ||
client := sshclient.Client{ | ||
Username: username, | ||
// TODO: change gateway to prod by default before relesing. | ||
Hostname: "gateway.dev.devbox.sh", | ||
} | ||
bytes, err := client.Exec("auth") | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
resp := &authResponse{} | ||
err = json.Unmarshal(bytes, resp) | ||
if err != nil { | ||
log.Fatal(err) | ||
} | ||
|
||
return resp.VMHostname | ||
} | ||
|
||
func syncFiles(username string, hostname string) error { | ||
// TODO: instead of id, have the server return the machine's name and use that | ||
// here to. It'll make things easier to debug. | ||
id, _, _ := strings.Cut(hostname, ".") | ||
_, err := mutagen.Sync(&mutagen.SessionSpec{ | ||
// If multiple projects can sync to the same machine, we need the name to also include | ||
// the project's id. | ||
Name: fmt.Sprintf("devbox-%s", id), | ||
AlphaPath: ".", // We should use location of `devbox.json` | ||
BetaAddress: fmt.Sprintf("%s@%s", username, hostname), | ||
// It's important that the beta path is a "clean" directory that will contain *only* | ||
// the projects files. If we pick a pre-existing directories with other files, those | ||
// files will be synced back to the local directory (due to two-way-sync) and pollute | ||
// the user's local project | ||
BetaPath: "~/Code/", | ||
IgnoreVCS: true, | ||
SyncMode: "two-way-resolved", | ||
}) | ||
if err != nil { | ||
return err | ||
} | ||
time.Sleep(1 * time.Second) | ||
return nil | ||
} | ||
|
||
func shell(username string, hostname string) error { | ||
client := &sshclient.Client{ | ||
Username: username, | ||
Hostname: hostname, | ||
} | ||
return client.Shell() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
package mutagen | ||
|
||
import ( | ||
"os" | ||
) | ||
|
||
// TODO: publish as it's own shared package that other binaries | ||
// can use. | ||
|
||
// IsDir returns true if the path exists *and* it is pointing to a directory. | ||
// | ||
// This function will traverse symbolic links to query information about the | ||
// destination file. | ||
// | ||
// This is a convenience function that coerces errors to false. If it cannot | ||
// read the path for any reason (including a permission error, or a broken | ||
// symbolic link) it returns false. | ||
func IsDir(path string) bool { | ||
info, err := os.Stat(path) | ||
if err != nil { | ||
return false | ||
} | ||
return info.IsDir() | ||
} | ||
|
||
// IsFile returns true if the path exists *and* it is pointing to a regular file. | ||
// | ||
// This function will traverse symbolic links to query information about the | ||
// destination file. | ||
// | ||
// This is a convenience function that coerces errors to false. If it cannot | ||
// read the path for any reason (including a permission error, or a broken | ||
// symbolic link) it returns false. | ||
func IsFile(path string) bool { | ||
info, err := os.Stat(path) | ||
if err != nil { | ||
return false | ||
} | ||
return info.Mode().IsRegular() | ||
} | ||
|
||
// IsSymlink returns true if the path exists *and* it is a symlink. | ||
// | ||
// It does *not* traverse symbolic links, and returns true even if the symlink | ||
// is broken. | ||
// | ||
// This is a convenience function that coerces errors to false. If it cannot | ||
// read the path for any reason (including a permission error) it returns false. | ||
func IsSymlink(path string) bool { | ||
info, err := os.Lstat(path) | ||
if err != nil { | ||
return false | ||
} | ||
return (info.Mode().Type() & os.ModeSymlink) == os.ModeSymlink | ||
} | ||
|
||
func ExistsOrErr(path string) error { | ||
_, err := os.Stat(path) | ||
return err | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
package mutagen | ||
|
||
import ( | ||
"fmt" | ||
"os" | ||
"path/filepath" | ||
"runtime" | ||
|
||
"github.com/cavaliergopher/grab/v3" | ||
) | ||
|
||
func InstallMutagenOnce(binPath string) error { | ||
if IsFile(binPath) { | ||
// Already installed, do nothing | ||
// TODO: ideally we would check that the right version | ||
// is installed, and maybe we should also validate | ||
// with a checksum. | ||
return nil | ||
} | ||
|
||
url := mutagenURL() | ||
installDir := filepath.Dir(binPath) | ||
|
||
return Install(url, installDir) | ||
} | ||
|
||
func Install(url string, installDir string) error { | ||
err := os.MkdirAll(installDir, 0755) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
// TODO: add checksum validation | ||
resp, err := grab.Get(os.TempDir(), url) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
tarPath := resp.Filename | ||
tarReader, err := os.Open(tarPath) | ||
if err != nil { | ||
return err | ||
} | ||
err = Untar(tarReader, installDir) | ||
if err != nil { | ||
return err | ||
} | ||
return nil | ||
} | ||
|
||
func mutagenURL() string { | ||
repo := "mutagen-io/mutagen" | ||
pkg := "mutagen" | ||
version := "v0.16.1" // Hard-coded for now, but change to always get the latest? | ||
platform := detectPlatform() | ||
|
||
return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s_%s_%s.tar.gz", repo, version, pkg, platform, version) | ||
} | ||
|
||
func detectOS() string { | ||
return runtime.GOOS | ||
} | ||
|
||
func detectArch() string { | ||
return runtime.GOARCH | ||
} | ||
|
||
func detectPlatform() string { | ||
os := detectOS() | ||
arch := detectArch() | ||
return fmt.Sprintf("%s_%s", os, arch) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
// Copyright 2022 Jetpack Technologies Inc and contributors. All rights reserved. | ||
// Use of this source code is governed by the license in the LICENSE file. | ||
|
||
package mutagen | ||
|
||
import ( | ||
"errors" | ||
) | ||
|
||
func Sync(spec *SessionSpec) (*Session, error) { | ||
if spec.Name == "" { | ||
return nil, errors.New("name is required") | ||
} | ||
|
||
// Check if there's an existing sessions or not | ||
sessions, err := List(spec.Name) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// If there isn't, create a new one | ||
if len(sessions) == 0 { | ||
err = Create(spec) | ||
if err != nil { | ||
return nil, err | ||
} | ||
} | ||
// Whether new or pre-existing, find the sessions object, ensure | ||
// that it's not paused, and return it. | ||
sessions, err = List(spec.Name) | ||
if err != nil { | ||
return nil, err | ||
} | ||
for _, session := range sessions { | ||
// TODO: should we handle errors for Reset and Resume differently? | ||
_ = Reset(session.Identifier) | ||
_ = Resume(session.Identifier) | ||
} | ||
if len(sessions) > 0 { | ||
return &sessions[0], nil | ||
} else { | ||
return nil, errors.New("failed to find session that was just created") | ||
} | ||
// TODO: starting the mutagen session currently fails if there's any error or | ||
// interactivity required for the ssh connection. | ||
// That includes: | ||
// - When connecting for the first time and adding the host to known_hosts | ||
// - When the key has changed and SSH warns of a man-in-the-middle attack | ||
} |
Oops, something went wrong.