mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat(ssh+cli): Support SSH CLI on machine init and add commands (#173)
* Add SSHCLIRemote type with buildSSHArgs Implement's Executor interface (Run, Stream, Close) * Add UseSSHCLI field to RemoteMachine struct Allow us to differentiate regular ssh from ssh+cli. * Parse ssh+cli:// scheme on machine init/add * Support SSH CLI when provisioning * Update docs to reflect ssh+cli:// support * Revert removal of `init` examples Fixes copy & pasta mistake when documenting the new ssh+cli connection methods.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
@@ -34,19 +35,31 @@ func NewAddCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add [USER@]HOST[:PORT]",
|
Use: "add [USER@]HOST[:PORT]",
|
||||||
Short: "Add a remote machine to a cluster.",
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine := &cli.RemoteMachine{
|
remoteMachine := &cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
|
UseSSHCLI: useSSHCLI,
|
||||||
}
|
}
|
||||||
|
|
||||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
@@ -34,8 +35,12 @@ func NewInitCommand() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init [USER@HOST:PORT]",
|
Use: "init [USER@HOST:PORT]",
|
||||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
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" +
|
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.",
|
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.
|
Example: ` # Initialise a new cluster with default settings.
|
||||||
uc machine init root@<your-server-ip>
|
uc machine init root@<your-server-ip>
|
||||||
|
|
||||||
@@ -55,15 +60,22 @@ func NewInitCommand() *cobra.Command {
|
|||||||
|
|
||||||
var remoteMachine *cli.RemoteMachine
|
var remoteMachine *cli.RemoteMachine
|
||||||
if len(args) > 0 {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("parse remote machine: %w", err)
|
return fmt.Errorf("parse remote machine: %w", err)
|
||||||
}
|
}
|
||||||
remoteMachine = &cli.RemoteMachine{
|
remoteMachine = &cli.RemoteMachine{
|
||||||
User: user,
|
User: user,
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
KeyPath: opts.sshKey,
|
KeyPath: opts.sshKey,
|
||||||
|
UseSSHCLI: useSSHCLI,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-2
@@ -228,9 +228,13 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions)
|
|||||||
|
|
||||||
// Save the machine's SSH connection details in the context config.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
|
||||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
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)
|
cli.Config.Contexts[contextName].Connections = append(cli.Config.Contexts[contextName].Connections, connCfg)
|
||||||
if err = cli.Config.Save(); err != nil {
|
if err = cli.Config.Save(); err != nil {
|
||||||
return nil, fmt.Errorf("save config: %w", err)
|
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.
|
// Save the machine's SSH connection details in the context config.
|
||||||
connCfg := config.MachineConnection{
|
connCfg := config.MachineConnection{
|
||||||
SSH: config.NewSSHDestination(opts.RemoteMachine.User, opts.RemoteMachine.Host, opts.RemoteMachine.Port),
|
|
||||||
SSHKeyFile: opts.RemoteMachine.KeyPath,
|
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 == "" {
|
if contextName == "" {
|
||||||
contextName = cli.Config.CurrentContext
|
contextName = cli.Config.CurrentContext
|
||||||
}
|
}
|
||||||
@@ -420,6 +428,35 @@ func (cli *CLI) AddMachine(ctx context.Context, opts AddMachineOptions) (*client
|
|||||||
func provisionOrConnectRemoteMachine(
|
func provisionOrConnectRemoteMachine(
|
||||||
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
ctx context.Context, remoteMachine *RemoteMachine, skipInstall bool, version string,
|
||||||
) (*client.Client, error) {
|
) (*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)
|
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 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 == "" {
|
if err != nil && remoteMachine.KeyPath == "" {
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RemoteMachine struct {
|
type RemoteMachine struct {
|
||||||
User string
|
User string
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
KeyPath string
|
KeyPath string
|
||||||
|
UseSSHCLI bool // indicates ssh+cli:// should be used
|
||||||
}
|
}
|
||||||
|
|
||||||
func installCmd(user string, version string) string {
|
func installCmd(user string, version string) string {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user