Skip to content

Commit

Permalink
Merge pull request #504 from FabianKramm/main
Browse files Browse the repository at this point in the history
feat: add jupyter notebook as ide
  • Loading branch information
FabianKramm authored Jul 11, 2023
2 parents 5b140d7 + c301ffc commit 021fc95
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 48 deletions.
3 changes: 3 additions & 0 deletions cmd/agent/container/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/loft-sh/devpod/pkg/devcontainer/setup"
"github.com/loft-sh/devpod/pkg/ide/fleet"
"github.com/loft-sh/devpod/pkg/ide/jetbrains"
"github.com/loft-sh/devpod/pkg/ide/jupyter"
"github.com/loft-sh/devpod/pkg/ide/openvscode"
"github.com/loft-sh/devpod/pkg/ide/vscode"
provider2 "github.com/loft-sh/devpod/pkg/provider"
Expand Down Expand Up @@ -125,6 +126,8 @@ func (cmd *SetupContainerCmd) installIDE(setupInfo *config.Result, workspaceInfo
return jetbrains.NewWebStormServer(config.GetRemoteUser(setupInfo), workspaceInfo.Workspace.IDE.Options, log).Install()
case string(config2.IDEFleet):
return fleet.NewFleetServer(config.GetRemoteUser(setupInfo), workspaceInfo.Workspace.IDE.Options, log).Install(setupInfo.SubstitutionContext.ContainerWorkspaceFolder)
case string(config2.IDEJupyterNotebook):
return jupyter.NewJupyterNotebookServer(setupInfo.SubstitutionContext.ContainerWorkspaceFolder, config.GetRemoteUser(setupInfo), workspaceInfo.Workspace.IDE.Options, log).Install()
}

return nil
Expand Down
125 changes: 93 additions & 32 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
config2 "github.com/loft-sh/devpod/pkg/devcontainer/config"
"github.com/loft-sh/devpod/pkg/ide/fleet"
"github.com/loft-sh/devpod/pkg/ide/jetbrains"
"github.com/loft-sh/devpod/pkg/ide/jupyter"
"github.com/loft-sh/devpod/pkg/ide/openvscode"
"github.com/loft-sh/devpod/pkg/ide/vscode"
open2 "github.com/loft-sh/devpod/pkg/open"
Expand Down Expand Up @@ -74,6 +75,7 @@ func NewUpCmd(flags *flags.GlobalFlags) *cobra.Command {
var logger log.Logger = log.Default
if cmd.Proxy {
logger = logger.ErrorStreamOnly()
logger.Debugf("Using error stream as --proxy is enabled")
}

devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider)
Expand Down Expand Up @@ -161,7 +163,7 @@ func (cmd *UpCmd) Run(ctx context.Context, devPodConfig *config.Config, client c
case string(config.IDEVSCode):
return startVSCodeLocally(client, result.SubstitutionContext.ContainerWorkspaceFolder, ideConfig.Options, log)
case string(config.IDEOpenVSCode):
return startInBrowser(ctx, devPodConfig, client, result.SubstitutionContext.ContainerWorkspaceFolder, user, ideConfig.Options, log)
return startVSCodeInBrowser(ctx, devPodConfig, client, result.SubstitutionContext.ContainerWorkspaceFolder, user, ideConfig.Options, log)
case string(config.IDEGoland):
return jetbrains.NewGolandServer(config2.GetRemoteUser(result), ideConfig.Options, log).OpenGateway(result.SubstitutionContext.ContainerWorkspaceFolder, client.Workspace())
case string(config.IDEPyCharm):
Expand All @@ -180,12 +182,49 @@ func (cmd *UpCmd) Run(ctx context.Context, devPodConfig *config.Config, client c
return jetbrains.NewWebStormServer(config2.GetRemoteUser(result), ideConfig.Options, log).OpenGateway(result.SubstitutionContext.ContainerWorkspaceFolder, client.Workspace())
case string(config.IDEFleet):
return startFleet(ctx, client, log)
case string(config.IDEJupyterNotebook):
return startJupyterNotebookInBrowser(ctx, devPodConfig, client, user, ideConfig.Options, log)
}
}

return nil
}

func startJupyterNotebookInBrowser(ctx context.Context, devPodConfig *config.Config, client client2.BaseWorkspaceClient, user string, ideOptions map[string]config.OptionValue, logger log.Logger) error {
// determine port
jupyterAddress, jupyterPort, err := parseAddressAndPort(jupyter.Options.GetValue(ideOptions, jupyter.BindAddressOption), jupyter.DefaultServerPort)
if err != nil {
return err
}

// wait until reachable then open browser
targetURL := fmt.Sprintf("http://localhost:%d", jupyterPort)
if jupyter.Options.GetValue(ideOptions, jupyter.OpenOption) == "true" {
go func() {
err = open2.Open(ctx, targetURL, logger)
if err != nil {
logger.Errorf("error opening jupyter notebook: %v", err)
}

logger.Infof("Successfully started jupyter notebook in browser mode. Please keep this terminal open as long as you use Jupyter Notebook")
}()
}

// start in browser
logger.Infof("Starting jupyter notebook in browser mode at %s", targetURL)
extraPorts := []string{fmt.Sprintf("%s:%d", jupyterAddress, jupyter.DefaultServerPort)}
return startBrowserTunnel(
ctx,
devPodConfig,
client,
user,
targetURL,
false,
extraPorts,
logger,
)
}

func startFleet(ctx context.Context, client client2.BaseWorkspaceClient, logger log.Logger) error {
// create ssh command
stdout := &bytes.Buffer{}
Expand Down Expand Up @@ -230,35 +269,11 @@ func startVSCodeLocally(client client2.BaseWorkspaceClient, workspaceFolder stri
return nil
}

func startInBrowser(ctx context.Context, devPodConfig *config.Config, client client2.BaseWorkspaceClient, workspaceFolder, user string, ideOptions map[string]config.OptionValue, logger log.Logger) error {
func startVSCodeInBrowser(ctx context.Context, devPodConfig *config.Config, client client2.BaseWorkspaceClient, workspaceFolder, user string, ideOptions map[string]config.OptionValue, logger log.Logger) error {
// determine port
var (
err error
vscodeAddress string
vscodePort int
)
if openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption) == "" {
vscodePort, err = port.FindAvailablePort(openvscode.DefaultVSCodePort)
if err != nil {
return err
}

vscodeAddress = fmt.Sprintf("%d", vscodePort)
} else {
vscodeAddress = openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption)
_, port, err := net.SplitHostPort(vscodeAddress)
if err != nil {
return fmt.Errorf("parse host:port: %w", err)
} else if port == "" {
return fmt.Errorf("parse ADDRESS: expected host:port, got %s", vscodeAddress)
}

vscodePort, err = strconv.Atoi(port)
if err != nil {
return fmt.Errorf("parse host:port: %w", err)
}

logger.Infof("Bind VSCode to %s...", vscodeAddress)
vscodeAddress, vscodePort, err := parseAddressAndPort(openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption), openvscode.DefaultVSCodePort)
if err != nil {
return err
}

// wait until reachable then open browser
Expand All @@ -276,7 +291,53 @@ func startInBrowser(ctx context.Context, devPodConfig *config.Config, client cli

// start in browser
logger.Infof("Starting vscode in browser mode at %s", targetURL)
err = tunnel.NewTunnel(
forwardPorts := openvscode.Options.GetValue(ideOptions, openvscode.ForwardPortsOption) == "true"
extraPorts := []string{fmt.Sprintf("%s:%d", vscodeAddress, openvscode.DefaultVSCodePort)}
return startBrowserTunnel(
ctx,
devPodConfig,
client,
user,
targetURL,
forwardPorts,
extraPorts,
logger,
)
}

func parseAddressAndPort(bindAddressOption string, defaultPort int) (string, int, error) {
var (
err error
address string
portName int
)
if bindAddressOption == "" {
portName, err = port.FindAvailablePort(defaultPort)
if err != nil {
return "", 0, err
}

address = fmt.Sprintf("%d", portName)
} else {
address = bindAddressOption
_, port, err := net.SplitHostPort(address)
if err != nil {
return "", 0, fmt.Errorf("parse host:port: %w", err)
} else if port == "" {
return "", 0, fmt.Errorf("parse ADDRESS: expected host:port, got %s", address)
}

portName, err = strconv.Atoi(port)
if err != nil {
return "", 0, fmt.Errorf("parse host:port: %w", err)
}
}

return address, portName, nil
}

func startBrowserTunnel(ctx context.Context, devPodConfig *config.Config, client client2.BaseWorkspaceClient, user, targetURL string, forwardPorts bool, extraPorts []string, logger log.Logger) error {
err := tunnel.NewTunnel(
ctx,
func(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
writer := logger.Writer(logrus.DebugLevel, false)
Expand Down Expand Up @@ -310,10 +371,10 @@ func startInBrowser(ctx context.Context, devPodConfig *config.Config, client cli
devPodConfig,
containerClient,
user,
openvscode.Options.GetValue(ideOptions, openvscode.ForwardPortsOption) == "true",
forwardPorts,
true,
true,
[]string{fmt.Sprintf("%s:%d", vscodeAddress, openvscode.DefaultVSCodePort)},
extraPorts,
logger,
)
if err != nil {
Expand Down
17 changes: 13 additions & 4 deletions pkg/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ func (e *Error) Error() string {
}

var exitError *exec.ExitError
if errors.As(e.err, &exitError) {
if len(exitError.Stderr) > 0 {
message += string(exitError.Stderr) + "\n"
}
if errors.As(e.err, &exitError) && len(exitError.Stderr) > 0 {
message += string(exitError.Stderr) + "\n"
}

return message + e.err.Error()
Expand All @@ -41,3 +39,14 @@ func Exists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}

func ExistsForUser(cmd, user string) bool {
command := "which " + cmd
var err error
if user == "" {
return Exists(cmd)
}

_, err = exec.Command("su", user, "-l", "-c", command).CombinedOutput()
return err == nil
}
25 changes: 13 additions & 12 deletions pkg/config/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ package config
type IDE string

const (
IDENone IDE = "none"
IDEVSCode IDE = "vscode"
IDEOpenVSCode IDE = "openvscode"
IDEIntellij IDE = "intellij"
IDEGoland IDE = "goland"
IDEPyCharm IDE = "pycharm"
IDEPhpStorm IDE = "phpstorm"
IDECLion IDE = "clion"
IDERubyMine IDE = "rubymine"
IDERider IDE = "rider"
IDEWebStorm IDE = "webstorm"
IDEFleet IDE = "fleet"
IDENone IDE = "none"
IDEVSCode IDE = "vscode"
IDEOpenVSCode IDE = "openvscode"
IDEIntellij IDE = "intellij"
IDEGoland IDE = "goland"
IDEPyCharm IDE = "pycharm"
IDEPhpStorm IDE = "phpstorm"
IDECLion IDE = "clion"
IDERubyMine IDE = "rubymine"
IDERider IDE = "rider"
IDEWebStorm IDE = "webstorm"
IDEFleet IDE = "fleet"
IDEJupyterNotebook IDE = "jupyternotebook"
)
7 changes: 7 additions & 0 deletions pkg/ide/ideparse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/loft-sh/devpod/pkg/ide"
"github.com/loft-sh/devpod/pkg/ide/fleet"
"github.com/loft-sh/devpod/pkg/ide/jetbrains"
"github.com/loft-sh/devpod/pkg/ide/jupyter"
"github.com/loft-sh/devpod/pkg/ide/openvscode"
"github.com/loft-sh/devpod/pkg/ide/vscode"
"github.com/loft-sh/devpod/pkg/provider"
Expand Down Expand Up @@ -101,6 +102,12 @@ var AllowedIDEs = []AllowedIDE{
Options: fleet.Options,
Icon: "https://devpod.sh/assets/fleet.svg",
},
{
Name: config.IDEJupyterNotebook,
DisplayName: "Jupyter Notebook",
Options: jupyter.Options,
Icon: "https://devpod.sh/assets/jupyter.svg",
},
}

func RefreshIDEOptions(devPodConfig *config.Config, workspace *provider.Workspace, ide string, options []string) (*provider.Workspace, error) {
Expand Down
113 changes: 113 additions & 0 deletions pkg/ide/jupyter/jupyter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package jupyter

import (
"fmt"
"os/exec"
"strconv"

"github.com/loft-sh/devpod/pkg/command"
"github.com/loft-sh/devpod/pkg/config"
"github.com/loft-sh/devpod/pkg/ide"
"github.com/loft-sh/devpod/pkg/single"
"github.com/loft-sh/log"
)

const (
OpenOption = "OPEN"
BindAddressOption = "BIND_ADDRESS"
)

var Options = ide.Options{
BindAddressOption: {
Name: BindAddressOption,
Description: "The address to bind the server to locally. E.g. 0.0.0.0:12345",
Default: "",
},
OpenOption: {
Name: OpenOption,
Description: "If DevPod should automatically open the browser",
Default: "true",
Enum: []string{
"true",
"false",
},
},
}

const DefaultServerPort = 10700

func NewJupyterNotebookServer(workspaceFolder string, userName string, values map[string]config.OptionValue, log log.Logger) *JupyterNotbookServer {
return &JupyterNotbookServer{
values: values,
workspaceFolder: workspaceFolder,
userName: userName,
log: log,
}
}

type JupyterNotbookServer struct {
values map[string]config.OptionValue
workspaceFolder string
userName string
log log.Logger
}

func (o *JupyterNotbookServer) Install() error {
err := o.installNotebook()
if err != nil {
return err
}

return o.Start()
}

func (o *JupyterNotbookServer) installNotebook() error {
if command.ExistsForUser("jupyter", o.userName) {
return nil
}

// check if pip3 exists
baseCommand := ""
if command.ExistsForUser("pip3", o.userName) {
baseCommand = "pip3"
} else if command.ExistsForUser("pip", o.userName) {
baseCommand = "pip"
} else {
return fmt.Errorf("seems like neither pip3 nor pip exists, please make sure to install python correctly")
}

// install notebook command
runCommand := fmt.Sprintf("%s install notebook", baseCommand)
args := []string{}
if o.userName != "" {
args = append(args, "su", o.userName, "-c", runCommand)
} else {
args = append(args, "sh", "-c", runCommand)
}

// install
o.log.Infof("Installing jupyter notebook...")
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
return fmt.Errorf("error installing jupyter notebook: %w", command.WrapCommandError(out, err))
}

o.log.Info("Successfully installed jupyter notebook")
return nil
}

func (o *JupyterNotbookServer) Start() error {
return single.Single("jupyter.pid", func() (*exec.Cmd, error) {
o.log.Infof("Starting jupyter notebook in background...")
runCommand := fmt.Sprintf("jupyter notebook --ip='*' --NotebookApp.notebook_dir='%s' --NotebookApp.token='' --NotebookApp.password='' --no-browser --port '%s' --allow-root", o.workspaceFolder, strconv.Itoa(DefaultServerPort))
args := []string{}
if o.userName != "" {
args = append(args, "su", o.userName, "-l", "-c", runCommand)
} else {
args = append(args, "sh", "-l", "-c", runCommand)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = o.workspaceFolder
return cmd, nil
})
}

0 comments on commit 021fc95

Please sign in to comment.