Skip to content

Commit

Permalink
[devbox] Add client code for devbox cloud (#310)
Browse files Browse the repository at this point in the history
## 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
loreto authored Nov 22, 2022
1 parent 299ae9e commit 3dedba4
Show file tree
Hide file tree
Showing 18 changed files with 1,007 additions and 0 deletions.
36 changes: 36 additions & 0 deletions boxcli/cloud.go
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()
}
3 changes: 3 additions & 0 deletions boxcli/doc-gen.go → boxcli/gen-docs.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// 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 (
Expand Down
1 change: 1 addition & 0 deletions boxcli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func RootCmd() *cobra.Command {
}
command.AddCommand(AddCmd())
command.AddCommand(BuildCmd())
command.AddCommand(CloudCmd())
command.AddCommand(GenerateCmd())
command.AddCommand(InfoCmd())
command.AddCommand(InitCmd())
Expand Down
1 change: 1 addition & 0 deletions boxcli/shell_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// 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 (
Expand Down
127 changes: 127 additions & 0 deletions cloud/cloud.go
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()
}
60 changes: 60 additions & 0 deletions cloud/mutagen/fileutil.go
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
}
72 changes: 72 additions & 0 deletions cloud/mutagen/install.go
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)
}
49 changes: 49 additions & 0 deletions cloud/mutagen/sync.go
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
}
Loading

0 comments on commit 3dedba4

Please sign in to comment.