diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go index dfba363d..204e327a 100644 --- a/cmd/uncloud/machine/add.go +++ b/cmd/uncloud/machine/add.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/netip" + "strings" "time" "github.com/cenkalti/backoff/v4" @@ -34,19 +35,31 @@ func NewAddCommand() *cobra.Command { cmd := &cobra.Command{ Use: "add [USER@]HOST[:PORT]", Short: "Add a remote machine to a cluster.", - Args: cobra.ExactArgs(1), + Long: `Add a new machine to an existing Uncloud cluster. + +Connection methods: + ssh://user@host - Use built-in SSH library (default, no prefix required) + ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { uncli := cmd.Context().Value("cli").(*cli.CLI) - user, host, port, err := config.SSHDestination(args[0]).Parse() + // Determine if SSH CLI needs to be used and strip scheme + destination := args[0] + useSSHCLI := strings.HasPrefix(destination, "ssh+cli://") + destination = strings.TrimPrefix(destination, "ssh+cli://") + destination = strings.TrimPrefix(destination, "ssh://") + + user, host, port, err := config.SSHDestination(destination).Parse() if err != nil { return fmt.Errorf("parse remote machine: %w", err) } remoteMachine := &cli.RemoteMachine{ - User: user, - Host: host, - Port: port, - KeyPath: opts.sshKey, + User: user, + Host: host, + Port: port, + KeyPath: opts.sshKey, + UseSSHCLI: useSSHCLI, } return add(cmd.Context(), uncli, remoteMachine, opts) diff --git a/cmd/uncloud/machine/init.go b/cmd/uncloud/machine/init.go index 46b4769c..511dac9d 100644 --- a/cmd/uncloud/machine/init.go +++ b/cmd/uncloud/machine/init.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/netip" + "strings" "github.com/docker/compose/v2/pkg/progress" "github.com/psviderski/uncloud/cmd/uncloud/caddy" @@ -34,8 +35,12 @@ func NewInitCommand() *cobra.Command { cmd := &cobra.Command{ Use: "init [USER@HOST:PORT]", Short: "Initialise a new cluster with a remote machine as the first member.", - Long: "Initialise a new cluster by setting up a remote machine as the first member.\n" + - "This command creates a new context in your Uncloud config to manage the cluster.", + Long: `Initialise a new cluster by setting up a remote machine as the first member. +This command creates a new context in your Uncloud config to manage the cluster. + +Connection methods: + ssh://user@host - Use built-in SSH library (default, no prefix required) + ssh+cli://user@host - Use system SSH command (supports ProxyJump, SSH config)`, Example: ` # Initialise a new cluster with default settings. uc machine init root@ @@ -55,15 +60,22 @@ func NewInitCommand() *cobra.Command { var remoteMachine *cli.RemoteMachine if len(args) > 0 { - user, host, port, err := config.SSHDestination(args[0]).Parse() + // Determine if SSH CLI is requested and strip scheme + destination := args[0] + useSSHCLI := strings.HasPrefix(destination, "ssh+cli://") + destination = strings.TrimPrefix(destination, "ssh+cli://") + destination = strings.TrimPrefix(destination, "ssh://") + + user, host, port, err := config.SSHDestination(destination).Parse() if err != nil { return fmt.Errorf("parse remote machine: %w", err) } remoteMachine = &cli.RemoteMachine{ - User: user, - Host: host, - Port: port, - KeyPath: opts.sshKey, + User: user, + Host: host, + Port: port, + KeyPath: opts.sshKey, + UseSSHCLI: useSSHCLI, } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 416e8b48..9ef6bd73 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -228,9 +228,13 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) // Save the machine's SSH connection details in the context config. connCfg := config.MachineConnection{ - SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port), SSHKeyFile: opts.RemoteMachine.KeyPath, } + if opts.RemoteMachine.UseSSHCLI { + connCfg.SSHCLI = config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port) + } else { + connCfg.SSH = config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port) + } cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg) if err = cli.Config.Save(); err != nil { return nil, fmt.Errorf("save config: %w", err) @@ -396,9 +400,13 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client // Save the machine's SSH connection details in the context config. connCfg := config.MachineConnection{ - SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port), SSHKeyFile: opts.RemoteMachine.KeyPath, } + if opts.RemoteMachine.UseSSHCLI { + connCfg.SSHCLI = config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port) + } else { + connCfg.SSH = config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port) + } if contextName == "" { contextName = cli.Config.CurrentContext } @@ -420,6 +428,35 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client func provisionOrConnectRemoteMachine( ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string, ) (*client.Client, error) { + // Use SSH CLI + if remoteMachine.UseSSHCLI { + exec := sshexec.NewSSHCLIRemote( + remoteMachine.User, + remoteMachine.Host, + remoteMachine.Port, + remoteMachine.KeyPath, + ) + + if !skipInstall { + if err := provisionMachine(ctx, exec, version); err != nil { + return nil, fmt.Errorf("provision machine: %w", err) + } + } + + sshConfig := &connector.SSHConnectorConfig{ + User: remoteMachine.User, + Host: remoteMachine.Host, + Port: remoteMachine.Port, + KeyPath: remoteMachine.KeyPath, + } + machineClient, err := client.New(ctx, connector.NewSSHCLIConnector(sshConfig)) + if err != nil { + return nil, fmt.Errorf("connect to remote machine: %w", err) + } + return machineClient, nil + } + + // Use Go SSH sshClient, err := sshexec.Connect(remoteMachine.User, remoteMachine.Host, remoteMachine.Port, remoteMachine.KeyPath) // If the SSH connection using SSH agent fails and no key path is provided, try to use the default SSH key. if err != nil && remoteMachine.KeyPath == "" { diff --git a/internal/cli/machine.go b/internal/cli/machine.go index 56f7c6d2..8c574088 100644 --- a/internal/cli/machine.go +++ b/internal/cli/machine.go @@ -21,10 +21,11 @@ const ( ) type RemoteMachine struct { - User string - Host string - Port int - KeyPath string + User string + Host string + Port int + KeyPath string + UseSSHCLI bool // indicates ssh+cli:// should be used } func installCmd(user string, version string) string { diff --git a/internal/sshexec/sshcli.go b/internal/sshexec/sshcli.go new file mode 100644 index 00000000..4428a8b5 --- /dev/null +++ b/internal/sshexec/sshcli.go @@ -0,0 +1,72 @@ +package sshexec + +import ( + "context" + "fmt" + "io" + "os/exec" + "strconv" + "strings" +) + +type SSHCLIRemote struct { + user string + host string + port int + keyPath string +} + +func NewSSHCLIRemote(user, host string, port int, keyPath string) *SSHCLIRemote { + return &SSHCLIRemote{ + user: user, + host: host, + port: port, + keyPath: keyPath, + } +} + +// TODO: Refactor and reuse this with buildDialArgs and buildSSHArgs from +// SSHCLI Connector. +func (r *SSHCLIRemote) buildSSHArgs() []string { + args := []string{"-o", "ConnectTimeout=5"} + + if r.port != 0 && r.port != 22 { + args = append(args, "-p", strconv.Itoa(r.port)) + } + + if r.keyPath != "" { + args = append(args, "-i", r.keyPath) + } + + args = append(args, r.user+"@"+r.host) + return args +} + +func (r *SSHCLIRemote) Run(ctx context.Context, cmd string) (string, error) { + args := r.buildSSHArgs() + args = append(args, cmd) + + execCmd := exec.CommandContext(ctx, "ssh", args...) + output, err := execCmd.CombinedOutput() + if err != nil { + return strings.TrimSpace(string(output)), + fmt.Errorf("run command on remote host: %w: %s", err, string(output)) + } + return strings.TrimSpace(string(output)), nil +} + +func (r *SSHCLIRemote) Stream(ctx context.Context, cmd string, stdout, stderr io.Writer) error { + args := r.buildSSHArgs() + args = append(args, cmd) + + execCmd := exec.CommandContext(ctx, "ssh", args...) + execCmd.Stdout = stdout + execCmd.Stderr = stderr + + return execCmd.Run() +} + +// no-op as there is no persistent connection. +func (r *SSHCLIRemote) Close() error { + return nil +} diff --git a/internal/sshexec/sshcli_test.go b/internal/sshexec/sshcli_test.go new file mode 100644 index 00000000..22cbda38 --- /dev/null +++ b/internal/sshexec/sshcli_test.go @@ -0,0 +1,76 @@ +package sshexec + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSSHCLIRemote_buildSSHArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + user string + host string + port int + keyPath string + expected []string + }{ + { + name: "default port, no key", + user: "root", + host: "example.com", + port: 22, + keyPath: "", + expected: []string{"-o", "ConnectTimeout=5", "root@example.com"}, + }, + { + name: "custom port", + user: "ubuntu", + host: "192.168.1.10", + port: 2222, + keyPath: "", + expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "ubuntu@192.168.1.10"}, + }, + { + name: "with key path", + user: "root", + host: "example.com", + port: 22, + keyPath: "/path/to/key", + expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com"}, + }, + { + name: "all options", + user: "admin", + host: "server.local", + port: 2222, + keyPath: "~/.ssh/id_rsa", + expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "~/.ssh/id_rsa", "admin@server.local"}, + }, + { + name: "port zero (default)", + user: "root", + host: "example.com", + port: 0, + keyPath: "", + expected: []string{"-o", "ConnectTimeout=5", "root@example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + remote := &SSHCLIRemote{ + user: tt.user, + host: tt.host, + port: tt.port, + keyPath: tt.keyPath, + } + result := remote.buildSSHArgs() + assert.Equal(t, tt.expected, result) + }) + } +}