Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[devbox] Add client code for devbox cloud #310

Merged
merged 3 commits into from
Nov 22, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
[devbox] Add client code for devbox cloud
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.
  • Loading branch information
loreto committed Nov 22, 2022
commit 58777169afe9e8ebd0e08f55c124e9c5a0709bc1
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
121 changes: 121 additions & 0 deletions cloud/cloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// 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"),
}
survey.AskOne(prompt, &username, survey.WithValidator(survey.Required))
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{}
json.Unmarshal(bytes, resp)

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)
}
45 changes: 45 additions & 0 deletions cloud/mutagen/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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 {
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