diff --git a/cmd/agent/workspace/up.go b/cmd/agent/workspace/up.go index aa3463ea2..cf2845a9f 100644 --- a/cmd/agent/workspace/up.go +++ b/cmd/agent/workspace/up.go @@ -208,8 +208,7 @@ func prepareWorkspace(ctx context.Context, workspaceInfo *provider2.AgentWorkspa } log.Debugf("Clone Repository") - gitCloner := git.NewCloner(workspaceInfo.CLIOptions.GitCloneStrategy) - err = CloneRepository(ctx, workspaceInfo.CLIOptions.SSHKey, workspaceInfo.Agent.Local == "true", workspaceInfo.ContentFolder, workspaceInfo.Workspace.Source, helper, gitCloner, log) + err = CloneRepository(ctx, workspaceInfo, helper, log) if err != nil { // fallback log.Errorf("Cloning failed: %v. Trying cloning on local machine and uploading folder", err) @@ -414,7 +413,8 @@ func (cmd *UpCmd) devPodUp(ctx context.Context, workspaceInfo *provider2.AgentWo return result, nil } -func CloneRepository(ctx context.Context, sshkey string, local bool, workspaceDir string, source provider2.WorkspaceSource, helper string, cloner git.Cloner, log log.Logger) error { +func CloneRepository(ctx context.Context, workspaceInfo *provider2.AgentWorkspaceInfo, helper string, log log.Logger) error { + workspaceDir := workspaceInfo.ContentFolder // remove the credential helper or otherwise we will receive strange errors within the container defer func() { if helper != "" { @@ -432,6 +432,7 @@ func CloneRepository(ctx context.Context, sshkey string, local bool, workspaceDi // check if command exists if !command.Exists("git") { + local, _ := workspaceInfo.Agent.Local.Bool() if local { return fmt.Errorf("seems like git isn't installed on your system. Please make sure to install git and make it available in the PATH") } @@ -485,10 +486,11 @@ func CloneRepository(ctx context.Context, sshkey string, local bool, workspaceDi } // run git command - gitInfo := git.NewGitInfo(source.GitRepository, source.GitBranch, source.GitCommit, source.GitPRReference, source.GitSubPath) + s := workspaceInfo.Workspace.Source + gitInfo := git.NewGitInfo(s.GitRepository, s.GitBranch, s.GitCommit, s.GitPRReference, s.GitSubPath) extraEnv := []string{} - if sshkey != "" { + if workspaceInfo.CLIOptions.SSHKey != "" { key, err := os.CreateTemp("", "") if err != nil { return err @@ -496,7 +498,7 @@ func CloneRepository(ctx context.Context, sshkey string, local bool, workspaceDi defer os.Remove(key.Name()) defer key.Close() - err = writeSSHKey(key, sshkey) + err = writeSSHKey(key, workspaceInfo.CLIOptions.SSHKey) if err != nil { return err } @@ -507,9 +509,11 @@ func CloneRepository(ctx context.Context, sshkey string, local bool, workspaceDi } extraEnv = append(extraEnv, "GIT_TERMINAL_PROMPT=0") - extraEnv = append(extraEnv, "GIT_SSH_COMMAND=ssh -oBatchMode=yes -o IdentitiesOnly=yes -oStrictHostKeyChecking=no -o IdentityFile="+key.Name()) + gitSSHCmd := []string{workspaceInfo.Agent.Path, "helper", "ssh-git-clone", "--key-file=" + key.Name()} + extraEnv = append(extraEnv, "GIT_SSH_COMMAND="+command.Quote(gitSSHCmd)) } + cloner := git.NewCloner(workspaceInfo.CLIOptions.GitCloneStrategy) err := git.CloneRepositoryWithEnv(ctx, gitInfo, extraEnv, workspaceDir, helper, false, cloner, writer, log) if err != nil { return errors.Wrap(err, "clone repository") diff --git a/cmd/helper/helper.go b/cmd/helper/helper.go index 8571ca420..adcdd0ec2 100644 --- a/cmd/helper/helper.go +++ b/cmd/helper/helper.go @@ -30,6 +30,7 @@ func NewHelperCmd(globalFlags *flags.GlobalFlags) *cobra.Command { helperCmd.AddCommand(NewCheckProviderUpdateCmd(globalFlags)) helperCmd.AddCommand(NewSSHClientCmd()) helperCmd.AddCommand(NewShellCmd()) + helperCmd.AddCommand(NewSSHGitCloneCmd()) helperCmd.AddCommand(NewFleetServerCmd(globalFlags)) helperCmd.AddCommand(NewDockerCredentialsHelperCmd(globalFlags)) return helperCmd diff --git a/cmd/helper/ssh_git_clone.go b/cmd/helper/ssh_git_clone.go new file mode 100644 index 000000000..a4e4f6cdd --- /dev/null +++ b/cmd/helper/ssh_git_clone.go @@ -0,0 +1,105 @@ +package helper + +import ( + "context" + "fmt" + "net" + "os" + "strings" + + command2 "github.com/loft-sh/devpod/pkg/command" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" +) + +type SSHGitClone struct { + KeyFile string + Port string +} + +func NewSSHGitCloneCmd() *cobra.Command { + cmd := &SSHGitClone{} + sshCmd := &cobra.Command{ + Use: "ssh-git-clone", + Short: "Drop-in ssh replacement in GIT_SSH_COMMAND", + RunE: func(_ *cobra.Command, args []string) error { + return cmd.Run(context.Background(), args) + }, + } + + sshCmd.Flags().StringVar(&cmd.KeyFile, "key-file", "", "SSH Key file to use") + sshCmd.Flags().StringVar(&cmd.Port, "port", "22", "SSH port to use, defaults to 22") + _ = sshCmd.MarkFlagRequired("key-file") + return sshCmd +} + +func (cmd *SSHGitClone) Run(ctx context.Context, args []string) error { + if len(args) < 2 { + return fmt.Errorf("expected args in format: {user}@{host} {commands...}, received \"%s\"", strings.Join(args, " ")) + } + host := args[0] + sshCmdArgs := args[1:] + if len(host) == 0 || len(sshCmdArgs) == 0 { + return fmt.Errorf("unexpected input: host: %s, args: %s", host, strings.Join(sshCmdArgs, " ")) + } + + user, addr, err := parseSSHHost(host) + if err != nil { + return err + } + + sshConfig, err := getConfig(user, cmd.KeyFile) + if err != nil { + return err + } + + sshClient, err := ssh.Dial("tcp", net.JoinHostPort(addr, cmd.Port), sshConfig) + if err != nil { + return err + } + defer sshClient.Close() + + sess, err := sshClient.NewSession() + if err != nil { + return err + } + defer sess.Close() + + sess.Stdin = os.Stdin + sess.Stdout = os.Stdout + sess.Stderr = os.Stderr + err = sess.Run(command2.Quote(sshCmdArgs)) + if err != nil { + return err + } + + return nil +} + +func getConfig(userName string, keyFilePath string) (*ssh.ClientConfig, error) { + out, err := os.ReadFile(keyFilePath) + if err != nil { + return nil, fmt.Errorf("read private ssh key: %w", err) + } + + signer, err := ssh.ParsePrivateKey(out) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + return &ssh.ClientConfig{ + User: userName, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }, nil +} + +func parseSSHHost(host string) (string, string, error) { + s := strings.SplitN(host, "@", 2) + if len(s) != 2 { + return "", "", fmt.Errorf("split host: %s", host) + } + + return s[0], s[1], nil +} diff --git a/pkg/types/types.go b/pkg/types/types.go index f5fb6b37a..29bb942ab 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -140,7 +140,7 @@ func (l *LifecycleHook) UnmarshalJSON(data []byte) error { type StrBool string // UnmarshalJSON parses fields that may be numbers or booleans. -func (f *StrBool) UnmarshalJSON(data []byte) error { +func (s *StrBool) UnmarshalJSON(data []byte) error { var jsonObj interface{} err := json.Unmarshal(data, &jsonObj) if err != nil { @@ -148,15 +148,23 @@ func (f *StrBool) UnmarshalJSON(data []byte) error { } switch obj := jsonObj.(type) { case string: - *f = StrBool(obj) + *s = StrBool(obj) return nil case bool: - *f = StrBool(strconv.FormatBool(obj)) + *s = StrBool(strconv.FormatBool(obj)) return nil } return ErrUnsupportedType } +func (s *StrBool) Bool() (bool, error) { + if s == nil { + return false, nil + } + + return strconv.ParseBool(string(*s)) +} + type OptionEnum struct { Value string `json:"value,omitempty"` DisplayName string `json:"displayName,omitempty"`