diff --git a/cmd/up.go b/cmd/up.go index 53edc923a..bd9be3922 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -1,54 +1,36 @@ package cmd import ( - "bytes" "context" "fmt" "io" - "net" "os" - "os/exec" "os/signal" "path/filepath" - "slices" "strconv" "strings" "syscall" - "github.com/blang/semver/v4" "github.com/sirupsen/logrus" "github.com/skevetter/devpod/cmd/flags" "github.com/skevetter/devpod/pkg/agent" "github.com/skevetter/devpod/pkg/agent/tunnelserver" client2 "github.com/skevetter/devpod/pkg/client" "github.com/skevetter/devpod/pkg/client/clientimplementation" - "github.com/skevetter/devpod/pkg/command" "github.com/skevetter/devpod/pkg/config" config2 "github.com/skevetter/devpod/pkg/devcontainer/config" "github.com/skevetter/devpod/pkg/devcontainer/sshtunnel" + "github.com/skevetter/devpod/pkg/dotfiles" "github.com/skevetter/devpod/pkg/ide" - "github.com/skevetter/devpod/pkg/ide/fleet" - "github.com/skevetter/devpod/pkg/ide/jetbrains" - "github.com/skevetter/devpod/pkg/ide/jupyter" - "github.com/skevetter/devpod/pkg/ide/openvscode" - "github.com/skevetter/devpod/pkg/ide/rstudio" - "github.com/skevetter/devpod/pkg/ide/vscode" - "github.com/skevetter/devpod/pkg/ide/zed" - open2 "github.com/skevetter/devpod/pkg/open" + "github.com/skevetter/devpod/pkg/ide/opener" options2 "github.com/skevetter/devpod/pkg/options" - "github.com/skevetter/devpod/pkg/platform" - "github.com/skevetter/devpod/pkg/port" provider2 "github.com/skevetter/devpod/pkg/provider" devssh "github.com/skevetter/devpod/pkg/ssh" "github.com/skevetter/devpod/pkg/telemetry" - "github.com/skevetter/devpod/pkg/tunnel" "github.com/skevetter/devpod/pkg/util" - "github.com/skevetter/devpod/pkg/version" workspace2 "github.com/skevetter/devpod/pkg/workspace" "github.com/skevetter/log" - "github.com/skratchdot/open-golang/open" "github.com/spf13/cobra" - "golang.org/x/crypto/ssh" ) // UpCmd holds the up cmd flags. @@ -382,15 +364,15 @@ func (cmd *UpCmd) configureWorkspace( log.Info("SSH configuration completed in workspace") } - if err := setupDotfiles( - cmd.DotfilesSource, - cmd.DotfilesScript, - cmd.DotfilesScriptEnvFile, - cmd.DotfilesScriptEnv, - client, - devPodConfig, - log, - ); err != nil { + if err := dotfiles.Setup(dotfiles.SetupParams{ + Source: cmd.DotfilesSource, + Script: cmd.DotfilesScript, + EnvFiles: cmd.DotfilesScriptEnvFile, + EnvKeyValues: cmd.DotfilesScriptEnv, + Client: client, + DevPodConfig: devPodConfig, + Log: log, + }); err != nil { return err } @@ -410,176 +392,16 @@ func (cmd *UpCmd) openIDE( } ideConfig := client.WorkspaceConfig().IDE - opener := newIDEOpener(cmd, devPodConfig, client, wctx, log) - return opener.open(ctx, ideConfig.Name, ideConfig.Options) -} - -// ideOpener handles opening different IDE types. -type ideOpener struct { - cmd *UpCmd - devPodConfig *config.Config - client client2.BaseWorkspaceClient - wctx *workspaceContext - log log.Logger -} - -func newIDEOpener( - cmd *UpCmd, - devPodConfig *config.Config, - client client2.BaseWorkspaceClient, - wctx *workspaceContext, - log log.Logger, -) *ideOpener { - return &ideOpener{ - cmd: cmd, - devPodConfig: devPodConfig, - client: client, - wctx: wctx, - log: log, - } -} - -func (o *ideOpener) open( - ctx context.Context, - ideName string, - ideOptions map[string]config.OptionValue, -) error { - folder := o.wctx.result.SubstitutionContext.ContainerWorkspaceFolder - workspace := o.client.Workspace() - user := o.wctx.user - - switch ideName { - case string(config.IDEVSCode), string(config.IDEVSCodeInsiders), string(config.IDECursor), - string(config.IDECodium), string(config.IDEPositron), string(config.IDEWindsurf), - string(config.IDEAntigravity), string(config.IDEBob): - return o.openVSCodeFlavor(ctx, ideName, folder, ideOptions) - - case string(config.IDERustRover), string(config.IDEGoland), string(config.IDEPyCharm), - string(config.IDEPhpStorm), string(config.IDEIntellij), string(config.IDECLion), - string(config.IDERider), string(config.IDERubyMine), string(config.IDEWebStorm), string(config.IDEDataSpell): - return o.openJetBrains(ideName, folder, workspace, user, ideOptions) - - case string(config.IDEOpenVSCode): - return startVSCodeInBrowser( - o.cmd.GPGAgentForwarding, - ctx, - o.devPodConfig, - o.client, - folder, - user, - ideOptions, - o.cmd.SSHAuthSockID, - o.cmd.GitSSHSigningKey, - o.log, - ) - - case string(config.IDEFleet): - return startFleet(ctx, o.client, o.log) - - case string(config.IDEZed): - return zed.Open(ctx, ideOptions, user, folder, workspace, o.log) - - case string(config.IDEJupyterNotebook): - return startJupyterNotebookInBrowser( - o.cmd.GPGAgentForwarding, - ctx, - o.devPodConfig, - o.client, - user, - ideOptions, - o.cmd.SSHAuthSockID, - o.cmd.GitSSHSigningKey, - o.log, - ) - - case string(config.IDERStudio): - return startRStudioInBrowser( - o.cmd.GPGAgentForwarding, - ctx, - o.devPodConfig, - o.client, - user, - ideOptions, - o.cmd.SSHAuthSockID, - o.cmd.GitSSHSigningKey, - o.log, - ) - - default: - return nil - } -} - -func (o *ideOpener) openVSCodeFlavor( - ctx context.Context, - ideName, folder string, - ideOptions map[string]config.OptionValue, -) error { - flavorMap := map[string]vscode.Flavor{ - string(config.IDEVSCode): vscode.FlavorStable, - string(config.IDEVSCodeInsiders): vscode.FlavorInsiders, - string(config.IDECursor): vscode.FlavorCursor, - string(config.IDECodium): vscode.FlavorCodium, - string(config.IDEPositron): vscode.FlavorPositron, - string(config.IDEWindsurf): vscode.FlavorWindsurf, - string(config.IDEAntigravity): vscode.FlavorAntigravity, - string(config.IDEBob): vscode.FlavorBob, - } - - params := vscode.OpenParams{ - Workspace: o.client.Workspace(), - Folder: folder, - NewWindow: vscode.Options.GetValue(ideOptions, vscode.OpenNewWindow) == config.BoolTrue, - Flavor: flavorMap[ideName], - Log: o.log, - } - - return vscode.Open(ctx, params) -} - -func (o *ideOpener) openJetBrains( - ideName, folder, workspace, user string, - ideOptions map[string]config.OptionValue, -) error { - type jetbrainsFactory func() interface{ OpenGateway(string, string) error } - - jetbrainsMap := map[string]jetbrainsFactory{ - string(config.IDERustRover): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewRustRoverServer(user, ideOptions, o.log) - }, - string(config.IDEGoland): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewGolandServer(user, ideOptions, o.log) - }, - string(config.IDEPyCharm): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewPyCharmServer(user, ideOptions, o.log) - }, - string(config.IDEPhpStorm): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewPhpStorm(user, ideOptions, o.log) - }, - string(config.IDEIntellij): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewIntellij(user, ideOptions, o.log) - }, - string(config.IDECLion): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewCLionServer(user, ideOptions, o.log) - }, - string(config.IDERider): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewRiderServer(user, ideOptions, o.log) - }, - string(config.IDERubyMine): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewRubyMineServer(user, ideOptions, o.log) - }, - string(config.IDEWebStorm): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewWebStormServer(user, ideOptions, o.log) - }, - string(config.IDEDataSpell): func() interface{ OpenGateway(string, string) error } { - return jetbrains.NewDataSpellServer(user, ideOptions, o.log) - }, - } - - if factory, ok := jetbrainsMap[ideName]; ok { - return factory().OpenGateway(folder, workspace) - } - return fmt.Errorf("unknown JetBrains IDE: %s", ideName) + return opener.Open(ctx, ideConfig.Name, ideConfig.Options, opener.Params{ + GPGAgentForwarding: cmd.GPGAgentForwarding, + SSHAuthSockID: cmd.SSHAuthSockID, + GitSSHSigningKey: cmd.GitSSHSigningKey, + DevPodConfig: devPodConfig, + Client: client, + User: wctx.user, + Result: wctx.result, + Log: log, + }) } func (cmd *UpCmd) devPodUp( @@ -837,431 +659,6 @@ func (cmd *UpCmd) devPodUpMachine( }) } -func startJupyterNotebookInBrowser( - forwardGpg bool, - ctx context.Context, - devPodConfig *config.Config, - client client2.BaseWorkspaceClient, - user string, - ideOptions map[string]config.OptionValue, - authSockID string, - gitSSHSigningKey string, - logger log.Logger, -) error { - if forwardGpg { - err := performGpgForwarding(client, logger) - if err != nil { - return err - } - } - - // 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/lab", jupyterPort) - if jupyter.Options.GetValue(ideOptions, jupyter.OpenOption) == config.BoolTrue { - go func() { - err = open2.Open(ctx, targetURL, logger) - if err != nil { - logger.WithFields(logrus.Fields{"error": err}). - Error("error opening jupyter notebook") - } - - logger.Info( - "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, - authSockID, - gitSSHSigningKey, - logger, - ) -} - -func startRStudioInBrowser( - forwardGpg bool, - ctx context.Context, - devPodConfig *config.Config, - client client2.BaseWorkspaceClient, - user string, - ideOptions map[string]config.OptionValue, - authSockID string, - gitSSHSigningKey string, - logger log.Logger, -) error { - if forwardGpg { - err := performGpgForwarding(client, logger) - if err != nil { - return err - } - } - - // determine port - addr, port, err := parseAddressAndPort( - rstudio.Options.GetValue(ideOptions, rstudio.BindAddressOption), - rstudio.DefaultServerPort, - ) - if err != nil { - return err - } - - // wait until reachable then open browser - targetURL := fmt.Sprintf("http://localhost:%d", port) - if rstudio.Options.GetValue(ideOptions, rstudio.OpenOption) == config.BoolTrue { - go func() { - err = open2.Open(ctx, targetURL, logger) - if err != nil { - logger.Errorf("error opening rstudio: %v", err) - } - - logger.Infof( - "started RStudio Server in browser mode. Please keep this terminal open as long as you use it", - ) - }() - } - - // start in browser - logger.Infof("Starting RStudio server in browser mode at %s", targetURL) - extraPorts := []string{fmt.Sprintf("%s:%d", addr, rstudio.DefaultServerPort)} - return startBrowserTunnel( - ctx, - devPodConfig, - client, - user, - targetURL, - false, - extraPorts, - authSockID, - gitSSHSigningKey, - logger, - ) -} - -func startFleet(ctx context.Context, client client2.BaseWorkspaceClient, logger log.Logger) error { - // create ssh command - stdout := &bytes.Buffer{} - cmd, err := createSSHCommand( - ctx, - client, - logger, - []string{"--command", "cat " + fleet.FleetURLFileName}, - ) - if err != nil { - return err - } - cmd.Stdout = stdout - err = cmd.Run() - if err != nil { - return command.WrapCommandError(stdout.Bytes(), err) - } - - url := strings.TrimSpace(stdout.String()) - if len(url) == 0 { - return fmt.Errorf("seems like fleet is not running within the container") - } - - logger.Warnf( - "Fleet is exposed at a publicly reachable URL, please make sure to not disclose this URL " + - "to anyone as they will be able to reach your workspace from that", - ) - logger.Infof("Starting Fleet at %s ...", url) - err = open.Run(url) - if err != nil { - return err - } - - return nil -} - -func startVSCodeInBrowser( - forwardGpg bool, - ctx context.Context, - devPodConfig *config.Config, - client client2.BaseWorkspaceClient, - workspaceFolder, user string, - ideOptions map[string]config.OptionValue, - authSockID string, - gitSSHSigningKey string, - logger log.Logger, -) error { - if forwardGpg { - err := performGpgForwarding(client, logger) - if err != nil { - return err - } - } - - // determine port - vscodeAddress, vscodePort, err := parseAddressAndPort( - openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption), - openvscode.DefaultVSCodePort, - ) - if err != nil { - return err - } - - // wait until reachable then open browser - targetURL := fmt.Sprintf("http://localhost:%d/?folder=%s", vscodePort, workspaceFolder) - if openvscode.Options.GetValue(ideOptions, openvscode.OpenOption) == config.BoolTrue { - go func() { - err = open2.Open(ctx, targetURL, logger) - if err != nil { - logger.Errorf("error opening vscode: %v", err) - } - - logger.Infof( - "started vscode in browser mode. Please keep this terminal open as long as you use VSCode browser version", - ) - }() - } - - // start in browser - logger.Infof("Starting vscode in browser mode at %s", targetURL) - forwardPorts := openvscode.Options.GetValue( - ideOptions, - openvscode.ForwardPortsOption, - ) == config.BoolTrue - extraPorts := []string{fmt.Sprintf("%s:%d", vscodeAddress, openvscode.DefaultVSCodePort)} - return startBrowserTunnel( - ctx, - devPodConfig, - client, - user, - targetURL, - forwardPorts, - extraPorts, - authSockID, - gitSSHSigningKey, - 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 -} - -// setupBackhaul sets up a long running command in the container to ensure an SSH connection is kept alive. -func setupBackhaul(client client2.BaseWorkspaceClient, authSockId string, log log.Logger) error { - execPath, err := os.Executable() - if err != nil { - return err - } - - remoteUser, err := devssh.GetUser( - client.WorkspaceConfig().ID, - client.WorkspaceConfig().SSHConfigPath, - client.WorkspaceConfig().SSHConfigIncludePath, - ) - if err != nil { - remoteUser = "root" - } - - dotCmd := exec.Command( - execPath, - "ssh", - "--agent-forwarding=true", - fmt.Sprintf("--reuse-ssh-auth-sock=%s", authSockId), - "--start-services=false", - "--user", - remoteUser, - "--context", - client.Context(), - client.Workspace(), - "--log-output=raw", - "--command", - "while true; do sleep 6000000; done", // sleep infinity is not available on all systems - ) - - if log.GetLevel() == logrus.DebugLevel { - dotCmd.Args = append(dotCmd.Args, "--debug") - } - - log.Info("Setting up backhaul SSH connection") - - writer := log.Writer(logrus.InfoLevel, false) - - dotCmd.Stdout = writer - dotCmd.Stderr = writer - - err = dotCmd.Run() - if err != nil { - return err - } - - log.Infof("Done setting up backhaul") - - return nil -} - -func startBrowserTunnel( - ctx context.Context, - devPodConfig *config.Config, - client client2.BaseWorkspaceClient, - user, targetURL string, - forwardPorts bool, - extraPorts []string, - authSockID string, - gitSSHSigningKey string, - logger log.Logger, -) error { - // Setup a backhaul SSH connection using the remote user so there is an AUTH SOCK to use - // With normal IDEs this would be the SSH connection made by the IDE - // authSockID is not set when in proxy mode since we cannot use the proxies ssh-agent - if authSockID != "" { - go func() { - if err := setupBackhaul(client, authSockID, logger); err != nil { - logger.Error("Failed to setup backhaul SSH connection: ", err) - } - }() - } - - // handle this directly with the daemon client - daemonClient, ok := client.(client2.DaemonClient) - if ok { - toolClient, _, err := daemonClient.SSHClients(ctx, user) - if err != nil { - return err - } - defer func() { _ = toolClient.Close() }() - - err = clientimplementation.StartServicesDaemon( - ctx, - clientimplementation.StartServicesDaemonOptions{ - DevPodConfig: devPodConfig, - Client: daemonClient, - SSHClient: toolClient, - User: user, - Log: logger, - ForwardPorts: forwardPorts, - ExtraPorts: extraPorts, - }, - ) - if err != nil { - return err - } - <-ctx.Done() - - return nil - } - - err := tunnel.NewTunnel( - ctx, - func(ctx context.Context, stdin io.Reader, stdout io.Writer) error { - writer := logger.Writer(logrus.DebugLevel, false) - defer func() { _ = writer.Close() }() - - cmd, err := createSSHCommand(ctx, client, logger, []string{ - "--log-output=raw", - fmt.Sprintf("--reuse-ssh-auth-sock=%s", authSockID), - "--stdio", - }) - if err != nil { - return err - } - cmd.Stdout = stdout - cmd.Stdin = stdin - cmd.Stderr = writer - return cmd.Run() - }, - func(ctx context.Context, containerClient *ssh.Client) error { - // print port to console - streamLogger, ok := logger.(*log.StreamLogger) - if ok { - streamLogger.JSON(logrus.InfoLevel, map[string]string{ - "url": targetURL, - "done": "true", - }) - } - - configureDockerCredentials := devPodConfig.ContextOption( - config.ContextOptionSSHInjectDockerCredentials, - ) == config.BoolTrue - configureGitCredentials := devPodConfig.ContextOption( - config.ContextOptionSSHInjectGitCredentials, - ) == config.BoolTrue - configureGitSSHSignatureHelper := devPodConfig.ContextOption( - config.ContextOptionGitSSHSignatureForwarding, - ) == config.BoolTrue - - // run in container - err := tunnel.RunServices( - ctx, - tunnel.RunServicesOptions{ - DevPodConfig: devPodConfig, - ContainerClient: containerClient, - User: user, - ForwardPorts: forwardPorts, - ExtraPorts: extraPorts, - PlatformOptions: nil, - Workspace: client.WorkspaceConfig(), - ConfigureDockerCredentials: configureDockerCredentials, - ConfigureGitCredentials: configureGitCredentials, - ConfigureGitSSHSignatureHelper: configureGitSSHSignatureHelper, - GitSSHSigningKey: gitSSHSigningKey, - Log: logger, - }, - ) - if err != nil { - return fmt.Errorf("run credentials server in browser tunnel: %w", err) - } - - <-ctx.Done() - return nil - }, - ) - if err != nil { - return err - } - - return nil -} - type configureSSHParams struct { sshConfigPath string sshConfigIncludePath string @@ -1354,344 +751,6 @@ var inheritedEnvironmentVariables = []string{ "GIT_COMMITTER_DATE", } -func createSSHCommand( - ctx context.Context, - client client2.BaseWorkspaceClient, - logger log.Logger, - extraArgs []string, -) (*exec.Cmd, error) { - execPath, err := os.Executable() - if err != nil { - return nil, err - } - - args := []string{ - "ssh", - "--user=root", - "--agent-forwarding=false", - "--start-services=false", - "--context", - client.Context(), - client.Workspace(), - } - if logger.GetLevel() == logrus.DebugLevel { - args = append(args, "--debug") - } - args = append(args, extraArgs...) - - return exec.CommandContext(ctx, execPath, args...), nil -} - -func setupDotfiles( - dotfiles, script string, - envFiles, envKeyValuePairs []string, - client client2.BaseWorkspaceClient, - devPodConfig *config.Config, - log log.Logger, -) error { - dotfilesRepo := devPodConfig.ContextOption(config.ContextOptionDotfilesURL) - if dotfiles != "" { - dotfilesRepo = dotfiles - } - - dotfilesScript := devPodConfig.ContextOption(config.ContextOptionDotfilesScript) - if script != "" { - dotfilesScript = script - } - - if dotfilesRepo == "" { - log.Debug("No dotfiles repo specified, skipping") - return nil - } - - log.Infof("Dotfiles Git repository %s specified", dotfilesRepo) - log.Debug("Cloning dotfiles into the devcontainer...") - - dotCmd, err := buildDotCmd( - devPodConfig, - dotfilesRepo, - dotfilesScript, - envFiles, - envKeyValuePairs, - client, - log, - ) - if err != nil { - return err - } - if log.GetLevel() == logrus.DebugLevel { - dotCmd.Args = append(dotCmd.Args, "--debug") - } - - log.Debugf("Running dotfiles setup command: %v", dotCmd.Args) - - writer := log.Writer(logrus.InfoLevel, false) - - dotCmd.Stdout = writer - dotCmd.Stderr = writer - - err = dotCmd.Run() - if err != nil { - return err - } - - log.Infof("Done setting up dotfiles into the devcontainer") - - return nil -} - -func buildDotCmdAgentArguments( - devPodConfig *config.Config, - dotfilesRepo, dotfilesScript string, - log log.Logger, -) []string { - agentArguments := []string{ - "agent", - "workspace", - "install-dotfiles", - "--repository", - dotfilesRepo, - } - - if devPodConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == config.BoolTrue { - agentArguments = append(agentArguments, "--strict-host-key-checking") - } - - if log.GetLevel() == logrus.DebugLevel { - agentArguments = append(agentArguments, "--debug") - } - - if dotfilesScript != "" { - log.Infof("Dotfiles script %s specified", dotfilesScript) - agentArguments = append(agentArguments, "--install-script", dotfilesScript) - } - - return agentArguments -} - -func buildDotCmd( - devPodConfig *config.Config, - dotfilesRepo, dotfilesScript string, - envFiles, envKeyValuePairs []string, - client client2.BaseWorkspaceClient, - log log.Logger, -) (*exec.Cmd, error) { - sshCmd := []string{ - "ssh", - "--agent-forwarding=true", - "--start-services=true", - } - - envFilesKeyValuePairs, err := collectDotfilesScriptEnvKeyvaluePairs(envFiles) - if err != nil { - return nil, err - } - - // Collect file-based and CLI options env variables names (aka keys) and - // configure ssh env var passthrough with send-env - allEnvKeyValuesPairs := slices.Concat(envFilesKeyValuePairs, envKeyValuePairs) - allEnvKeys := extractKeysFromEnvKeyValuePairs(allEnvKeyValuesPairs) - for _, envKey := range allEnvKeys { - sshCmd = append(sshCmd, "--send-env", envKey) - } - - remoteUser, err := devssh.GetUser( - client.WorkspaceConfig().ID, - client.WorkspaceConfig().SSHConfigPath, - client.WorkspaceConfig().SSHConfigIncludePath, - ) - if err != nil { - remoteUser = "root" - } - - agentArguments := buildDotCmdAgentArguments(devPodConfig, dotfilesRepo, dotfilesScript, log) - sshCmd = append(sshCmd, - "--user", - remoteUser, - "--context", - client.Context(), - client.Workspace(), - "--log-output=raw", - "--command", - agent.ContainerDevPodHelperLocation+" "+strings.Join(agentArguments, " "), - ) - execPath, err := os.Executable() - if err != nil { - return nil, err - } - - dotCmd := exec.Command( - execPath, - sshCmd..., - ) - - dotCmd.Env = append(dotCmd.Environ(), allEnvKeyValuesPairs...) - return dotCmd, nil -} - -func extractKeysFromEnvKeyValuePairs(envKeyValuePairs []string) []string { - keys := []string{} - for _, env := range envKeyValuePairs { - keyValue := strings.SplitN(env, "=", 2) - if len(keyValue) == 2 { - keys = append(keys, keyValue[0]) - } - } - return keys -} - -func collectDotfilesScriptEnvKeyvaluePairs(envFiles []string) ([]string, error) { - keyValues := []string{} - for _, file := range envFiles { - envFromFile, err := config2.ParseKeyValueFile(file) - if err != nil { - return nil, err - } - keyValues = append(keyValues, envFromFile...) - } - return keyValues, nil -} - -func performGpgForwarding( - client client2.BaseWorkspaceClient, - log log.Logger, -) error { - log.Debug("gpg forwarding enabled, performing immediately") - - execPath, err := os.Executable() - if err != nil { - return err - } - - remoteUser, err := devssh.GetUser( - client.WorkspaceConfig().ID, - client.WorkspaceConfig().SSHConfigPath, - client.WorkspaceConfig().SSHConfigIncludePath, - ) - if err != nil { - remoteUser = "root" - } - - log.Info("forwarding gpg-agent") - - // perform in background an ssh command forwarding the - // gpg agent, in order to have it immediately take effect - go func() { - err = exec.Command( - execPath, - "ssh", - "--gpg-agent-forwarding=true", - "--agent-forwarding=true", - "--start-services=true", - "--user", - remoteUser, - "--context", - client.Context(), - client.Workspace(), - "--log-output=raw", - "--command", "sleep infinity", - ).Run() - if err != nil { - log.Error("failure in forwarding gpg-agent") - } - }() - - return nil -} - -// checkProviderUpdate currently only ensures the local provider is in sync with the remote for DevPod Pro instances -// Potentially auto-upgrade other providers in the future. -func checkProviderUpdate( - devPodConfig *config.Config, - proInstance *provider2.ProInstance, - log log.Logger, -) error { - if version.GetVersion() == version.DevVersion { - log.Debugf("skipping provider upgrade check during development") - return nil - } - if proInstance == nil { - log.Debug("no pro instance available, skipping provider upgrade check") - return nil - } - - // compare versions - newVersion, err := platform.GetProInstanceDevPodVersion(proInstance) - if err != nil { - return fmt.Errorf("version for pro instance %s: %w", proInstance.Host, err) - } - - p, err := workspace2.FindProvider(devPodConfig, proInstance.Provider, log) - if err != nil { - return fmt.Errorf("get provider config for pro provider %s: %w", proInstance.Provider, err) - } - if p.Config.Version == version.DevVersion { - return nil - } - if p.Config.Source.Internal { - return nil - } - - v1, err := semver.Parse(strings.TrimPrefix(newVersion, "v")) - if err != nil { - return fmt.Errorf("parse version %s: %w", newVersion, err) - } - v2, err := semver.Parse(strings.TrimPrefix(p.Config.Version, "v")) - if err != nil { - return fmt.Errorf("parse version %s: %w", p.Config.Version, err) - } - if v1.Compare(v2) == 0 { - return nil - } - log.Infof( - "New provider version available, attempting to update %s from %s to %s", - proInstance.Provider, - p.Config.Version, - newVersion, - ) - - providerSource, err := workspace2.ResolveProviderSource(devPodConfig, proInstance.Provider, log) - if err != nil { - return fmt.Errorf("resolve provider source %s: %w", proInstance.Provider, err) - } - - splitted := strings.Split(providerSource, "@") - if len(splitted) == 0 { - return fmt.Errorf("no provider source found %s", providerSource) - } - providerSource = splitted[0] + "@" + newVersion - - _, err = workspace2.UpdateProvider(devPodConfig, proInstance.Provider, providerSource, log) - if err != nil { - return fmt.Errorf("update provider %s: %w", proInstance.Provider, err) - } - - log.WithFields(logrus.Fields{ - "provider": proInstance.Provider, - }).Done("updated provider") - return nil -} - -func getProInstance( - devPodConfig *config.Config, - providerName string, - log log.Logger, -) *provider2.ProInstance { - proInstances, err := workspace2.ListProInstances(devPodConfig, log) - if err != nil { - return nil - } else if len(proInstances) == 0 { - return nil - } - - proInstance, ok := workspace2.FindProviderProInstance(proInstances, providerName) - if !ok { - return nil - } - - return proInstance -} - func (cmd *UpCmd) prepareClient( ctx context.Context, devPodConfig *config.Config, @@ -1765,8 +824,8 @@ func (cmd *UpCmd) prepareClient( } if !cmd.Platform.Enabled { - proInstance := getProInstance(devPodConfig, client.Provider(), logger) - err = checkProviderUpdate(devPodConfig, proInstance, logger) + proInstance := workspace2.GetProInstance(devPodConfig, client.Provider(), logger) + err = workspace2.CheckProviderUpdate(devPodConfig, proInstance, logger) if err != nil { return nil, logger, err } diff --git a/pkg/dotfiles/dotfiles.go b/pkg/dotfiles/dotfiles.go new file mode 100644 index 000000000..28bf552ba --- /dev/null +++ b/pkg/dotfiles/dotfiles.go @@ -0,0 +1,205 @@ +package dotfiles + +import ( + "os" + "os/exec" + "slices" + "strings" + + "github.com/sirupsen/logrus" + "github.com/skevetter/devpod/pkg/agent" + client2 "github.com/skevetter/devpod/pkg/client" + "github.com/skevetter/devpod/pkg/config" + config2 "github.com/skevetter/devpod/pkg/devcontainer/config" + devssh "github.com/skevetter/devpod/pkg/ssh" + "github.com/skevetter/log" +) + +// SetupParams holds all parameters needed for dotfiles setup. +type SetupParams struct { + Source string + Script string + EnvFiles []string + EnvKeyValues []string + Client client2.BaseWorkspaceClient + DevPodConfig *config.Config + Log log.Logger +} + +// Setup clones and installs dotfiles into the devcontainer. +func Setup(p SetupParams) error { + dotfilesRepo := p.DevPodConfig.ContextOption(config.ContextOptionDotfilesURL) + if p.Source != "" { + dotfilesRepo = p.Source + } + + dotfilesScript := p.DevPodConfig.ContextOption(config.ContextOptionDotfilesScript) + if p.Script != "" { + dotfilesScript = p.Script + } + + if dotfilesRepo == "" { + p.Log.Debug("No dotfiles repo specified, skipping") + return nil + } + + p.Log.Infof("Dotfiles Git repository %s specified", dotfilesRepo) + p.Log.Debug("Cloning dotfiles into the devcontainer...") + + dotCmd, err := buildDotCmd(buildDotCmdParams{ + devPodConfig: p.DevPodConfig, + dotfilesRepo: dotfilesRepo, + dotfilesScript: dotfilesScript, + envFiles: p.EnvFiles, + envKeyValuePairs: p.EnvKeyValues, + client: p.Client, + log: p.Log, + }) + if err != nil { + return err + } + if p.Log.GetLevel() == logrus.DebugLevel { + dotCmd.Args = append(dotCmd.Args, "--debug") + } + + p.Log.Debugf("Running dotfiles setup command: %v", dotCmd.Args) + + writer := p.Log.Writer(logrus.InfoLevel, false) + + dotCmd.Stdout = writer + dotCmd.Stderr = writer + + err = dotCmd.Run() + if err != nil { + return err + } + + p.Log.Infof("Done setting up dotfiles into the devcontainer") + + return nil +} + +func buildDotCmdAgentArguments( + dotfilesRepo, dotfilesScript string, + strictHostKey, debug bool, +) []string { + agentArguments := []string{ + "agent", + "workspace", + "install-dotfiles", + "--repository", + dotfilesRepo, + } + + if strictHostKey { + agentArguments = append(agentArguments, "--strict-host-key-checking") + } + + if debug { + agentArguments = append(agentArguments, "--debug") + } + + if dotfilesScript != "" { + agentArguments = append(agentArguments, "--install-script", dotfilesScript) + } + + return agentArguments +} + +type buildDotCmdParams struct { + devPodConfig *config.Config + dotfilesRepo string + dotfilesScript string + envFiles []string + envKeyValuePairs []string + client client2.BaseWorkspaceClient + log log.Logger +} + +func buildDotCmd(p buildDotCmdParams) (*exec.Cmd, error) { + sshCmd := []string{ + "ssh", + "--agent-forwarding=true", + "--start-services=true", + } + + envFilesKeyValuePairs, err := collectDotfilesScriptEnvKeyValuePairs(p.envFiles) + if err != nil { + return nil, err + } + + // Collect file-based and CLI options env variables names (aka keys) and + // configure ssh env var passthrough with send-env + allEnvKeyValuesPairs := slices.Concat(envFilesKeyValuePairs, p.envKeyValuePairs) + allEnvKeys := extractKeysFromEnvKeyValuePairs(allEnvKeyValuesPairs) + for _, envKey := range allEnvKeys { + sshCmd = append(sshCmd, "--send-env", envKey) + } + + remoteUser, err := devssh.GetUser( + p.client.WorkspaceConfig().ID, + p.client.WorkspaceConfig().SSHConfigPath, + p.client.WorkspaceConfig().SSHConfigIncludePath, + ) + if err != nil { + remoteUser = "root" + } + + strictHostKey := p.devPodConfig.ContextOption( + config.ContextOptionSSHStrictHostKeyChecking, + ) == config.BoolTrue + debug := p.log.GetLevel() == logrus.DebugLevel + agentArguments := buildDotCmdAgentArguments( + p.dotfilesRepo, p.dotfilesScript, strictHostKey, debug, + ) + + if p.dotfilesScript != "" { + p.log.Infof("Dotfiles script %s specified", p.dotfilesScript) + } + + sshCmd = append(sshCmd, + "--user", + remoteUser, + "--context", + p.client.Context(), + p.client.Workspace(), + "--log-output=raw", + "--command", + agent.ContainerDevPodHelperLocation+" "+strings.Join(agentArguments, " "), + ) + execPath, err := os.Executable() + if err != nil { + return nil, err + } + + dotCmd := exec.Command( //nolint:gosec + execPath, + sshCmd..., + ) + + dotCmd.Env = append(dotCmd.Environ(), allEnvKeyValuesPairs...) + return dotCmd, nil +} + +func extractKeysFromEnvKeyValuePairs(envKeyValuePairs []string) []string { + keys := []string{} + for _, env := range envKeyValuePairs { + keyValue := strings.SplitN(env, "=", 2) + if len(keyValue) == 2 { + keys = append(keys, keyValue[0]) + } + } + return keys +} + +func collectDotfilesScriptEnvKeyValuePairs(envFiles []string) ([]string, error) { + keyValues := []string{} + for _, file := range envFiles { + envFromFile, err := config2.ParseKeyValueFile(file) + if err != nil { + return nil, err + } + keyValues = append(keyValues, envFromFile...) + } + return keyValues, nil +} diff --git a/pkg/dotfiles/dotfiles_test.go b/pkg/dotfiles/dotfiles_test.go new file mode 100644 index 000000000..d95a2302e --- /dev/null +++ b/pkg/dotfiles/dotfiles_test.go @@ -0,0 +1,121 @@ +package dotfiles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractKeysFromEnvKeyValuePairs(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "empty input", + input: []string{}, + expected: []string{}, + }, + { + name: "single key-value pair", + input: []string{"FOO=bar"}, + expected: []string{"FOO"}, + }, + { + name: "multiple key-value pairs", + input: []string{"FOO=bar", "BAZ=qux"}, + expected: []string{"FOO", "BAZ"}, + }, + { + name: "value contains equals sign", + input: []string{"FOO=bar=baz"}, + expected: []string{"FOO"}, + }, + { + name: "entry without equals sign is skipped", + input: []string{"NOEQ", "FOO=bar"}, + expected: []string{"FOO"}, + }, + { + name: "empty value", + input: []string{"FOO="}, + expected: []string{"FOO"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractKeysFromEnvKeyValuePairs(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCollectDotfilesScriptEnvKeyValuePairs(t *testing.T) { + t.Run("empty file list", func(t *testing.T) { + result, err := collectDotfilesScriptEnvKeyValuePairs([]string{}) + assert.NoError(t, err) + assert.Equal(t, []string{}, result) + }) + + t.Run("nonexistent file returns error", func(t *testing.T) { + _, err := collectDotfilesScriptEnvKeyValuePairs([]string{"/nonexistent/file"}) + assert.Error(t, err) + }) +} + +func TestBuildDotCmdAgentArguments(t *testing.T) { + tests := []struct { + name string + dotfilesRepo string + dotfilesScript string + strictHostKey bool + debug bool + expected []string + }{ + { + name: "basic repo only", + dotfilesRepo: "https://github.com/user/dotfiles", + expected: []string{ + "agent", "workspace", "install-dotfiles", + "--repository", "https://github.com/user/dotfiles", + }, + }, + { + name: "with script", + dotfilesRepo: "https://github.com/user/dotfiles", + dotfilesScript: "install.sh", + expected: []string{ + "agent", "workspace", "install-dotfiles", + "--repository", "https://github.com/user/dotfiles", + "--install-script", "install.sh", + }, + }, + { + name: "all options enabled", + dotfilesRepo: "https://github.com/user/dotfiles", + dotfilesScript: "setup.sh", + strictHostKey: true, + debug: true, + expected: []string{ + "agent", "workspace", "install-dotfiles", + "--repository", "https://github.com/user/dotfiles", + "--strict-host-key-checking", "--debug", + "--install-script", "setup.sh", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildDotCmdAgentArguments( + tt.dotfilesRepo, + tt.dotfilesScript, + tt.strictHostKey, + tt.debug, + ) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/gpg/forward.go b/pkg/gpg/forward.go new file mode 100644 index 000000000..cbbe1bb98 --- /dev/null +++ b/pkg/gpg/forward.go @@ -0,0 +1,58 @@ +package gpg + +import ( + "os" + "os/exec" + + client2 "github.com/skevetter/devpod/pkg/client" + devssh "github.com/skevetter/devpod/pkg/ssh" + "github.com/skevetter/log" +) + +// ForwardAgent starts a background SSH connection that forwards the local GPG agent. +func ForwardAgent(client client2.BaseWorkspaceClient, logger log.Logger) error { + logger.Debug("gpg forwarding enabled, performing immediately") + + execPath, err := os.Executable() + if err != nil { + return err + } + + remoteUser, err := devssh.GetUser( + client.WorkspaceConfig().ID, + client.WorkspaceConfig().SSHConfigPath, + client.WorkspaceConfig().SSHConfigIncludePath, + ) + if err != nil { + remoteUser = "root" + } + + logger.Info("forwarding gpg-agent") + + args := buildForwardArgs(remoteUser, client.Context(), client.Workspace()) + + go func() { + //nolint:gosec // execPath comes from os.Executable() + if runErr := exec.Command(execPath, args...).Run(); runErr != nil { + logger.Errorf("failure in forwarding gpg-agent: %v", runErr) + } + }() + + return nil +} + +func buildForwardArgs(user, context, workspace string) []string { + return []string{ + "ssh", + "--gpg-agent-forwarding=true", + "--agent-forwarding=true", + "--start-services=true", + "--user", + user, + "--context", + context, + workspace, + "--log-output=raw", + "--command", "sleep infinity", + } +} diff --git a/pkg/gpg/forward_test.go b/pkg/gpg/forward_test.go new file mode 100644 index 000000000..d8a599dec --- /dev/null +++ b/pkg/gpg/forward_test.go @@ -0,0 +1,23 @@ +package gpg + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildForwardArgs(t *testing.T) { + got := buildForwardArgs("root", "test-context", "test-workspace") + expected := []string{ + "ssh", + "--gpg-agent-forwarding=true", + "--agent-forwarding=true", + "--start-services=true", + "--user", "root", + "--context", "test-context", + "test-workspace", + "--log-output=raw", + "--command", "sleep infinity", + } + assert.Equal(t, expected, got) +} diff --git a/pkg/ide/opener/opener.go b/pkg/ide/opener/opener.go new file mode 100644 index 000000000..72c4056b9 --- /dev/null +++ b/pkg/ide/opener/opener.go @@ -0,0 +1,439 @@ +package opener + +import ( + "bytes" + "context" + "fmt" + "net" + "strconv" + "strings" + + "github.com/sirupsen/logrus" + client2 "github.com/skevetter/devpod/pkg/client" + "github.com/skevetter/devpod/pkg/client/clientimplementation" + "github.com/skevetter/devpod/pkg/command" + "github.com/skevetter/devpod/pkg/config" + config2 "github.com/skevetter/devpod/pkg/devcontainer/config" + "github.com/skevetter/devpod/pkg/gpg" + "github.com/skevetter/devpod/pkg/ide/fleet" + "github.com/skevetter/devpod/pkg/ide/jetbrains" + "github.com/skevetter/devpod/pkg/ide/jupyter" + "github.com/skevetter/devpod/pkg/ide/openvscode" + "github.com/skevetter/devpod/pkg/ide/rstudio" + "github.com/skevetter/devpod/pkg/ide/vscode" + "github.com/skevetter/devpod/pkg/ide/zed" + open2 "github.com/skevetter/devpod/pkg/open" + "github.com/skevetter/devpod/pkg/port" + "github.com/skevetter/devpod/pkg/tunnel" + "github.com/skevetter/log" + "github.com/skratchdot/open-golang/open" +) + +// Params holds the parameters needed to open an IDE. +type Params struct { + GPGAgentForwarding bool + SSHAuthSockID string + GitSSHSigningKey string + DevPodConfig *config.Config + Client client2.BaseWorkspaceClient + User string + Result *config2.Result + Log log.Logger +} + +// Open dispatches to the correct IDE opener based on ideName. +func Open( + ctx context.Context, + ideName string, + ideOptions map[string]config.OptionValue, + params Params, +) error { + if fn, ok := browserIDEOpener(ideName); ok { + return fn(ctx, ideOptions, params) + } + + return openDesktopIDE(ctx, ideName, ideOptions, params) +} + +// browserIDEOpener returns a handler for browser-based IDEs if ideName matches. +func browserIDEOpener( + ideName string, +) (func(context.Context, map[string]config.OptionValue, Params) error, bool) { + switch ideName { + case string(config.IDEOpenVSCode): + return openVSCodeBrowser, true + case string(config.IDEJupyterNotebook): + return openJupyterBrowser, true + case string(config.IDERStudio): + return openRStudioBrowser, true + default: + return nil, false + } +} + +func openDesktopIDE( + ctx context.Context, + ideName string, + ideOptions map[string]config.OptionValue, + params Params, +) error { + switch ideName { + case string(config.IDEVSCode), string(config.IDEVSCodeInsiders), string(config.IDECursor), + string(config.IDECodium), string(config.IDEPositron), string(config.IDEWindsurf), + string(config.IDEAntigravity), string(config.IDEBob): + return openVSCodeFlavor(ctx, ideName, ideOptions, params) + + case string(config.IDERustRover), string(config.IDEGoland), string(config.IDEPyCharm), + string(config.IDEPhpStorm), string(config.IDEIntellij), string(config.IDECLion), + string(config.IDERider), string(config.IDERubyMine), string(config.IDEWebStorm), + string(config.IDEDataSpell): + return openJetBrains(ideName, ideOptions, params) + + case string(config.IDEFleet): + return startFleet(ctx, params.Client, params.Log) + + case string(config.IDEZed): + return zed.Open( + ctx, ideOptions, params.User, + params.Result.SubstitutionContext.ContainerWorkspaceFolder, + params.Client.Workspace(), params.Log, + ) + + default: + return nil + } +} + +// ParseAddressAndPort parses a bind address option into host address and port. +// If bindAddressOption is empty, it finds an available port starting from defaultPort. +func ParseAddressAndPort(bindAddressOption string, defaultPort int) (string, int, error) { + if bindAddressOption == "" { + return parseDefaultPort(defaultPort) + } + + return parseExplicitAddress(bindAddressOption) +} + +func parseDefaultPort(defaultPort int) (string, int, error) { + portName, err := port.FindAvailablePort(defaultPort) + if err != nil { + return "", 0, err + } + + return fmt.Sprintf("%d", portName), portName, nil +} + +func parseExplicitAddress(address string) (string, int, error) { + _, p, err := net.SplitHostPort(address) + if err != nil { + return "", 0, fmt.Errorf("parse host:port: %w", err) + } + if p == "" { + return "", 0, fmt.Errorf("parse ADDRESS: expected host:port, got %s", address) + } + + portName, err := strconv.Atoi(p) + if err != nil { + return "", 0, fmt.Errorf("parse host:port: %w", err) + } + + return address, portName, nil +} + +var vsCodeFlavorMap = map[string]vscode.Flavor{ + string(config.IDEVSCode): vscode.FlavorStable, + string(config.IDEVSCodeInsiders): vscode.FlavorInsiders, + string(config.IDECursor): vscode.FlavorCursor, + string(config.IDECodium): vscode.FlavorCodium, + string(config.IDEPositron): vscode.FlavorPositron, + string(config.IDEWindsurf): vscode.FlavorWindsurf, + string(config.IDEAntigravity): vscode.FlavorAntigravity, + string(config.IDEBob): vscode.FlavorBob, +} + +func openVSCodeFlavor( + ctx context.Context, + ideName string, + ideOptions map[string]config.OptionValue, + params Params, +) error { + return vscode.Open(ctx, vscode.OpenParams{ + Workspace: params.Client.Workspace(), + Folder: params.Result.SubstitutionContext.ContainerWorkspaceFolder, + NewWindow: vscode.Options.GetValue(ideOptions, vscode.OpenNewWindow) == config.BoolTrue, + Flavor: vsCodeFlavorMap[ideName], + Log: params.Log, + }) +} + +func openJetBrains( + ideName string, + ideOptions map[string]config.OptionValue, + params Params, +) error { + folder := params.Result.SubstitutionContext.ContainerWorkspaceFolder + workspace := params.Client.Workspace() + user := params.User + logger := params.Log + type jetbrainsFactory func() interface{ OpenGateway(string, string) error } + + jetbrainsMap := map[string]jetbrainsFactory{ + string(config.IDERustRover): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewRustRoverServer(user, ideOptions, logger) + }, + string(config.IDEGoland): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewGolandServer(user, ideOptions, logger) + }, + string(config.IDEPyCharm): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewPyCharmServer(user, ideOptions, logger) + }, + string(config.IDEPhpStorm): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewPhpStorm(user, ideOptions, logger) + }, + string(config.IDEIntellij): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewIntellij(user, ideOptions, logger) + }, + string(config.IDECLion): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewCLionServer(user, ideOptions, logger) + }, + string(config.IDERider): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewRiderServer(user, ideOptions, logger) + }, + string(config.IDERubyMine): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewRubyMineServer(user, ideOptions, logger) + }, + string(config.IDEWebStorm): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewWebStormServer(user, ideOptions, logger) + }, + string(config.IDEDataSpell): func() interface{ OpenGateway(string, string) error } { + return jetbrains.NewDataSpellServer(user, ideOptions, logger) + }, + } + + if factory, ok := jetbrainsMap[ideName]; ok { + return factory().OpenGateway(folder, workspace) + } + return fmt.Errorf("unknown JetBrains IDE: %s", ideName) +} + +func makeDaemonStartFunc( + params Params, + forwardPorts bool, + extraPorts []string, +) func(ctx context.Context) error { + daemonClient, ok := params.Client.(client2.DaemonClient) + if !ok { + return nil + } + + return func(ctx context.Context) error { + toolClient, _, err := daemonClient.SSHClients(ctx, params.User) + if err != nil { + return err + } + defer func() { _ = toolClient.Close() }() + + err = clientimplementation.StartServicesDaemon( + ctx, + clientimplementation.StartServicesDaemonOptions{ + DevPodConfig: params.DevPodConfig, + Client: daemonClient, + SSHClient: toolClient, + User: params.User, + Log: params.Log, + ForwardPorts: forwardPorts, + ExtraPorts: extraPorts, + }, + ) + if err != nil { + return err + } + <-ctx.Done() + + return nil + } +} + +func openJupyterBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params Params, +) error { + if params.GPGAgentForwarding { + if err := gpg.ForwardAgent(params.Client, params.Log); err != nil { + return err + } + } + + addr, jupyterPort, err := ParseAddressAndPort( + jupyter.Options.GetValue(ideOptions, jupyter.BindAddressOption), + jupyter.DefaultServerPort, + ) + if err != nil { + return err + } + + targetURL := fmt.Sprintf("http://localhost:%d/lab", jupyterPort) + if jupyter.Options.GetValue(ideOptions, jupyter.OpenOption) == config.BoolTrue { + go func() { + if openErr := open2.Open(ctx, targetURL, params.Log); openErr != nil { + params.Log.WithFields(logrus.Fields{"error": openErr}). + Error("error opening jupyter notebook") + } + + params.Log.Info( + "started jupyter notebook in browser mode. " + + "Please keep this terminal open as long as you use Jupyter Notebook", + ) + }() + } + + params.Log.Infof("Starting jupyter notebook in browser mode at %s", targetURL) + extraPorts := []string{fmt.Sprintf("%s:%d", addr, jupyter.DefaultServerPort)} + return tunnel.StartBrowserTunnel(tunnel.BrowserTunnelParams{ + Ctx: ctx, + DevPodConfig: params.DevPodConfig, + Client: params.Client, + User: params.User, + TargetURL: targetURL, + ExtraPorts: extraPorts, + AuthSockID: params.SSHAuthSockID, + GitSSHSigningKey: params.GitSSHSigningKey, + Logger: params.Log, + DaemonStartFunc: makeDaemonStartFunc(params, false, extraPorts), + }) +} + +func openRStudioBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params Params, +) error { + if params.GPGAgentForwarding { + if err := gpg.ForwardAgent(params.Client, params.Log); err != nil { + return err + } + } + + addr, rsPort, err := ParseAddressAndPort( + rstudio.Options.GetValue(ideOptions, rstudio.BindAddressOption), + rstudio.DefaultServerPort, + ) + if err != nil { + return err + } + + targetURL := fmt.Sprintf("http://localhost:%d", rsPort) + if rstudio.Options.GetValue(ideOptions, rstudio.OpenOption) == config.BoolTrue { + go func() { + if openErr := open2.Open(ctx, targetURL, params.Log); openErr != nil { + params.Log.Errorf("error opening rstudio: %v", openErr) + } + + params.Log.Infof( + "started RStudio Server in browser mode. Please keep this terminal open as long as you use it", + ) + }() + } + + params.Log.Infof("Starting RStudio server in browser mode at %s", targetURL) + extraPorts := []string{fmt.Sprintf("%s:%d", addr, rstudio.DefaultServerPort)} + return tunnel.StartBrowserTunnel(tunnel.BrowserTunnelParams{ + Ctx: ctx, + DevPodConfig: params.DevPodConfig, + Client: params.Client, + User: params.User, + TargetURL: targetURL, + ExtraPorts: extraPorts, + AuthSockID: params.SSHAuthSockID, + GitSSHSigningKey: params.GitSSHSigningKey, + Logger: params.Log, + DaemonStartFunc: makeDaemonStartFunc(params, false, extraPorts), + }) +} + +func openVSCodeBrowser( + ctx context.Context, + ideOptions map[string]config.OptionValue, + params Params, +) error { + if params.GPGAgentForwarding { + if err := gpg.ForwardAgent(params.Client, params.Log); err != nil { + return err + } + } + + folder := params.Result.SubstitutionContext.ContainerWorkspaceFolder + addr, vscodePort, err := ParseAddressAndPort( + openvscode.Options.GetValue(ideOptions, openvscode.BindAddressOption), + openvscode.DefaultVSCodePort, + ) + if err != nil { + return err + } + + targetURL := fmt.Sprintf("http://localhost:%d/?folder=%s", vscodePort, folder) + if openvscode.Options.GetValue(ideOptions, openvscode.OpenOption) == config.BoolTrue { + go func() { + if openErr := open2.Open(ctx, targetURL, params.Log); openErr != nil { + params.Log.Errorf("error opening vscode: %v", openErr) + } + + params.Log.Infof( + "started vscode in browser mode. " + + "Please keep this terminal open as long as you use VSCode browser version", + ) + }() + } + + params.Log.Infof("Starting vscode in browser mode at %s", targetURL) + forwardPorts := openvscode.Options.GetValue( + ideOptions, + openvscode.ForwardPortsOption, + ) == config.BoolTrue + extraPorts := []string{fmt.Sprintf("%s:%d", addr, openvscode.DefaultVSCodePort)} + return tunnel.StartBrowserTunnel(tunnel.BrowserTunnelParams{ + Ctx: ctx, + DevPodConfig: params.DevPodConfig, + Client: params.Client, + User: params.User, + TargetURL: targetURL, + ForwardPorts: forwardPorts, + ExtraPorts: extraPorts, + AuthSockID: params.SSHAuthSockID, + GitSSHSigningKey: params.GitSSHSigningKey, + Logger: params.Log, + DaemonStartFunc: makeDaemonStartFunc(params, forwardPorts, extraPorts), + }) +} + +func startFleet(ctx context.Context, client client2.BaseWorkspaceClient, logger log.Logger) error { + stdout := &bytes.Buffer{} + sshCmd, err := tunnel.CreateSSHCommand( + ctx, + client, + logger, + []string{"--command", "cat " + fleet.FleetURLFileName}, + ) + if err != nil { + return err + } + sshCmd.Stdout = stdout + err = sshCmd.Run() + if err != nil { + return command.WrapCommandError(stdout.Bytes(), err) + } + + url := strings.TrimSpace(stdout.String()) + if len(url) == 0 { + return fmt.Errorf("seems like fleet is not running within the container") + } + + logger.Warnf( + "Fleet is exposed at a publicly reachable URL, please make sure to not disclose this URL " + + "to anyone as they will be able to reach your workspace from that", + ) + logger.Infof("Starting Fleet at %s ...", url) + + return open.Run(url) +} diff --git a/pkg/ide/opener/opener_test.go b/pkg/ide/opener/opener_test.go new file mode 100644 index 000000000..405156e7c --- /dev/null +++ b/pkg/ide/opener/opener_test.go @@ -0,0 +1,64 @@ +package opener + +import ( + "testing" +) + +func TestParseAddressAndPort_Empty(t *testing.T) { + addr, p, err := ParseAddressAndPort("", 10000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p < 10000 { + t.Errorf("expected port >= 10000, got %d", p) + } + if addr == "" { + t.Error("expected non-empty address") + } +} + +func TestParseAddressAndPort_Explicit(t *testing.T) { + tests := []struct { + name string + input string + wantAddr string + wantPort int + }{ + {"host:port", "127.0.0.1:8080", "127.0.0.1:8080", 8080}, + {"localhost:port", "localhost:3000", "localhost:3000", 3000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, p, err := ParseAddressAndPort(tt.input, 10000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addr != tt.wantAddr { + t.Errorf("addr = %q, want %q", addr, tt.wantAddr) + } + if p != tt.wantPort { + t.Errorf("port = %d, want %d", p, tt.wantPort) + } + }) + } +} + +func TestParseAddressAndPort_Errors(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"missing port", "127.0.0.1"}, + {"invalid format", "not:a:valid:address"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := ParseAddressAndPort(tt.input, 10000) + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} diff --git a/pkg/tunnel/browser.go b/pkg/tunnel/browser.go new file mode 100644 index 000000000..1d55bc0af --- /dev/null +++ b/pkg/tunnel/browser.go @@ -0,0 +1,215 @@ +package tunnel + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + + "github.com/sirupsen/logrus" + client2 "github.com/skevetter/devpod/pkg/client" + "github.com/skevetter/devpod/pkg/config" + devssh "github.com/skevetter/devpod/pkg/ssh" + "github.com/skevetter/log" + "golang.org/x/crypto/ssh" +) + +// BrowserTunnelParams bundles the arguments for browser-based IDE tunnels. +type BrowserTunnelParams struct { + Ctx context.Context + DevPodConfig *config.Config + Client client2.BaseWorkspaceClient + User string + TargetURL string + ForwardPorts bool + ExtraPorts []string + AuthSockID string + GitSSHSigningKey string + Logger log.Logger + + // DaemonStartFunc is called when the client is a DaemonClient. + // If nil, the SSH tunnel path is always used. + DaemonStartFunc func(ctx context.Context) error +} + +// StartBrowserTunnel sets up a browser tunnel for IDE access, either via daemon or SSH. +func StartBrowserTunnel(p BrowserTunnelParams) error { + if p.AuthSockID != "" { + go func() { + if err := SetupBackhaul(p.Client, p.AuthSockID, p.Logger); err != nil { + p.Logger.Error("Failed to setup backhaul SSH connection: ", err) + } + }() + } + + if p.DaemonStartFunc != nil { + return p.DaemonStartFunc(p.Ctx) + } + + return startBrowserTunnelSSH(p) +} + +func startBrowserTunnelSSH(p BrowserTunnelParams) error { + return NewTunnel( + p.Ctx, + func(ctx context.Context, stdin io.Reader, stdout io.Writer) error { + writer := p.Logger.Writer(logrus.DebugLevel, false) + defer func() { _ = writer.Close() }() + + sshCmd, err := CreateSSHCommand(ctx, p.Client, p.Logger, []string{ + "--log-output=raw", + fmt.Sprintf("--reuse-ssh-auth-sock=%s", p.AuthSockID), + "--stdio", + }) + if err != nil { + return err + } + sshCmd.Stdout = stdout + sshCmd.Stdin = stdin + sshCmd.Stderr = writer + return sshCmd.Run() + }, + func(ctx context.Context, containerClient *ssh.Client) error { + return runBrowserTunnelServices(ctx, p, containerClient) + }, + ) +} + +func runBrowserTunnelServices( + ctx context.Context, + p BrowserTunnelParams, + containerClient *ssh.Client, +) error { + streamLogger, ok := p.Logger.(*log.StreamLogger) + if ok { + streamLogger.JSON(logrus.InfoLevel, map[string]string{ + "url": p.TargetURL, + "done": "true", + }) + } + + err := RunServices( + ctx, + RunServicesOptions{ + DevPodConfig: p.DevPodConfig, + ContainerClient: containerClient, + User: p.User, + ForwardPorts: p.ForwardPorts, + ExtraPorts: p.ExtraPorts, + Workspace: p.Client.WorkspaceConfig(), + ConfigureDockerCredentials: p.DevPodConfig.ContextOption( + config.ContextOptionSSHInjectDockerCredentials, + ) == config.BoolTrue, + ConfigureGitCredentials: p.DevPodConfig.ContextOption( + config.ContextOptionSSHInjectGitCredentials, + ) == config.BoolTrue, + ConfigureGitSSHSignatureHelper: p.DevPodConfig.ContextOption( + config.ContextOptionGitSSHSignatureForwarding, + ) == config.BoolTrue, + GitSSHSigningKey: p.GitSSHSigningKey, + Log: p.Logger, + }, + ) + if err != nil { + return fmt.Errorf("run credentials server in browser tunnel: %w", err) + } + + <-ctx.Done() + return nil +} + +// SetupBackhaul sets up a long-running SSH connection for backhaul. +func SetupBackhaul(client client2.BaseWorkspaceClient, authSockID string, logger log.Logger) error { + execPath, err := os.Executable() + if err != nil { + return err + } + + remoteUser, err := devssh.GetUser( + client.WorkspaceConfig().ID, + client.WorkspaceConfig().SSHConfigPath, + client.WorkspaceConfig().SSHConfigIncludePath, + ) + if err != nil { + remoteUser = "root" + } + + //nolint:gosec // execPath is the current binary, arguments are controlled + backhaulCmd := exec.Command( + execPath, + "ssh", + "--agent-forwarding=true", + fmt.Sprintf("--reuse-ssh-auth-sock=%s", authSockID), + "--start-services=false", + "--user", + remoteUser, + "--context", + client.Context(), + client.Workspace(), + "--log-output=raw", + "--command", + "while true; do sleep 6000000; done", + ) + + if logger.GetLevel() == logrus.DebugLevel { + backhaulCmd.Args = append(backhaulCmd.Args, "--debug") + } + + logger.Info("Setting up backhaul SSH connection") + + writer := logger.Writer(logrus.InfoLevel, false) + + backhaulCmd.Stdout = writer + backhaulCmd.Stderr = writer + + err = backhaulCmd.Run() + if err != nil { + return err + } + + logger.Infof("Done setting up backhaul") + + return nil +} + +// CreateSSHCommand builds an exec.Cmd that runs `devpod ssh` with the given arguments. +func CreateSSHCommand( + ctx context.Context, + client client2.BaseWorkspaceClient, + logger log.Logger, + extraArgs []string, +) (*exec.Cmd, error) { + execPath, err := os.Executable() + if err != nil { + return nil, err + } + + args := buildSSHCommandArgs( + client.Context(), + client.Workspace(), + logger.GetLevel() == logrus.DebugLevel, + extraArgs, + ) + + //nolint:gosec // execPath is the current binary, arguments are controlled + return exec.CommandContext(ctx, execPath, args...), nil +} + +// buildSSHCommandArgs constructs the argument list for `devpod ssh`. +func buildSSHCommandArgs(clientContext, workspace string, debug bool, extraArgs []string) []string { + args := []string{ + "ssh", + "--user=root", + "--agent-forwarding=false", + "--start-services=false", + "--context", + clientContext, + workspace, + } + if debug { + args = append(args, "--debug") + } + args = append(args, extraArgs...) + return args +} diff --git a/pkg/tunnel/browser_test.go b/pkg/tunnel/browser_test.go new file mode 100644 index 000000000..dd626187a --- /dev/null +++ b/pkg/tunnel/browser_test.go @@ -0,0 +1,52 @@ +package tunnel + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func baseSSHArgs(ctx, ws string) []string { + return []string{ + "ssh", "--user=root", "--agent-forwarding=false", + "--start-services=false", "--context", ctx, ws, + } +} + +func TestBuildSSHCommandArgs(t *testing.T) { + tests := []struct { + name string + context string + workspace string + debug bool + extraArgs []string + expected []string + }{ + { + name: "basic", context: "default", workspace: "my-workspace", + expected: baseSSHArgs("default", "my-workspace"), + }, + { + name: "with debug", context: "default", workspace: "my-workspace", + debug: true, + expected: append(baseSSHArgs("default", "my-workspace"), "--debug"), + }, + { + name: "with extra args", context: "prod", workspace: "ws", + extraArgs: []string{"--stdio", "--log-output=raw"}, + expected: append(baseSSHArgs("prod", "ws"), "--stdio", "--log-output=raw"), + }, + { + name: "with debug and extra args", context: "default", workspace: "my-workspace", + debug: true, extraArgs: []string{"--stdio"}, + expected: append(baseSSHArgs("default", "my-workspace"), "--debug", "--stdio"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSSHCommandArgs(tt.context, tt.workspace, tt.debug, tt.extraArgs) + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/pkg/workspace/provider_update.go b/pkg/workspace/provider_update.go new file mode 100644 index 000000000..476ddead0 --- /dev/null +++ b/pkg/workspace/provider_update.go @@ -0,0 +1,135 @@ +package workspace + +import ( + "fmt" + "strings" + + "github.com/blang/semver/v4" + "github.com/sirupsen/logrus" + "github.com/skevetter/devpod/pkg/config" + "github.com/skevetter/devpod/pkg/platform" + provider2 "github.com/skevetter/devpod/pkg/provider" + "github.com/skevetter/devpod/pkg/version" + "github.com/skevetter/log" +) + +// CheckProviderUpdate currently only ensures the local provider is in sync with the remote for DevPod Pro instances. +// Potentially auto-upgrade other providers in the future. +func CheckProviderUpdate( + devPodConfig *config.Config, + proInstance *provider2.ProInstance, + log log.Logger, +) error { + if version.GetVersion() == version.DevVersion { + log.Debugf("skipping provider upgrade check during development") + return nil + } + if proInstance == nil { + log.Debug("no pro instance available, skipping provider upgrade check") + return nil + } + + return checkProviderUpdateForInstance(devPodConfig, proInstance, log) +} + +func checkProviderUpdateForInstance( + devPodConfig *config.Config, + proInstance *provider2.ProInstance, + log log.Logger, +) error { + newVersion, err := platform.GetProInstanceDevPodVersion(proInstance) + if err != nil { + return fmt.Errorf("version for pro instance %s: %w", proInstance.Host, err) + } + + p, err := FindProvider(devPodConfig, proInstance.Provider, log) + if err != nil { + return fmt.Errorf("get provider config for pro provider %s: %w", proInstance.Provider, err) + } + + if shouldSkipProviderUpdate(p.Config.Version == version.DevVersion, p.Config.Source.Internal) { + return nil + } + + needsUpdate, err := providerVersionNeedsUpdate(newVersion, p.Config.Version) + if err != nil { + return err + } + if !needsUpdate { + return nil + } + + log.Infof( + "New provider version available, attempting to update %s from %s to %s", + proInstance.Provider, + p.Config.Version, + newVersion, + ) + + return applyProviderUpdate(devPodConfig, proInstance.Provider, newVersion, log) +} + +func providerVersionNeedsUpdate(newVersion, currentVersion string) (bool, error) { + v1, err := semver.Parse(strings.TrimPrefix(newVersion, "v")) + if err != nil { + return false, fmt.Errorf("parse version %s: %w", newVersion, err) + } + v2, err := semver.Parse(strings.TrimPrefix(currentVersion, "v")) + if err != nil { + return false, fmt.Errorf("parse version %s: %w", currentVersion, err) + } + return v1.Compare(v2) != 0, nil +} + +func applyProviderUpdate( + devPodConfig *config.Config, + providerName, newVersion string, + log log.Logger, +) error { + providerSource, err := ResolveProviderSource(devPodConfig, providerName, log) + if err != nil { + return fmt.Errorf("resolve provider source %s: %w", providerName, err) + } + + splitted := strings.Split(providerSource, "@") + if len(splitted) == 0 { + return fmt.Errorf("no provider source found %s", providerSource) + } + providerSource = splitted[0] + "@" + newVersion + + _, err = UpdateProvider(devPodConfig, providerName, providerSource, log) + if err != nil { + return fmt.Errorf("update provider %s: %w", providerName, err) + } + + log.WithFields(logrus.Fields{ + "provider": providerName, + }).Done("updated provider") + return nil +} + +// GetProInstance returns the ProInstance associated with the given provider name, or nil if not found. +func GetProInstance( + devPodConfig *config.Config, + providerName string, + log log.Logger, +) *provider2.ProInstance { + proInstances, err := ListProInstances(devPodConfig, log) + if err != nil { + return nil + } else if len(proInstances) == 0 { + return nil + } + + proInstance, ok := FindProviderProInstance(proInstances, providerName) + if !ok { + return nil + } + + return proInstance +} + +// shouldSkipProviderUpdate returns true if the provider update check should be skipped. +func shouldSkipProviderUpdate(isDevVersion, isInternal bool) bool { + return isDevVersion || isInternal +} diff --git a/pkg/workspace/provider_update_test.go b/pkg/workspace/provider_update_test.go new file mode 100644 index 000000000..4f1ad78c4 --- /dev/null +++ b/pkg/workspace/provider_update_test.go @@ -0,0 +1,75 @@ +package workspace + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldSkipProviderUpdate(t *testing.T) { + tests := []struct { + name string + isDevVersion bool + isInternal bool + expected bool + }{ + { + name: "skip when dev version", + isDevVersion: true, + isInternal: false, + expected: true, + }, + { + name: "skip when internal", + isDevVersion: false, + isInternal: true, + expected: true, + }, + { + name: "skip when both dev version and internal", + isDevVersion: true, + isInternal: true, + expected: true, + }, + { + name: "do not skip for regular provider", + isDevVersion: false, + isInternal: false, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := shouldSkipProviderUpdate(tt.isDevVersion, tt.isInternal) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestProviderVersionNeedsUpdate(t *testing.T) { + tests := []struct { + name, newVer, curVer string + expected, expectErr bool + }{ + {"same version", "v0.5.0", "v0.5.0", false, false}, + {"newer version", "v0.6.0", "v0.5.0", true, false}, + {"older version (downgrade)", "v0.4.0", "v0.5.0", true, false}, + {"mixed v prefix", "v0.6.0", "0.5.0", true, false}, + {"patch difference", "v1.2.4", "v1.2.3", true, false}, + {"invalid new version", "not-a-version", "v0.5.0", false, true}, + {"invalid current version", "v0.5.0", "not-a-version", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := providerVersionNeedsUpdate(tt.newVer, tt.curVer) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +}