From a0a66f34c417e4c5a6eb3c5e5c814c1659086a27 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Tue, 18 Mar 2025 14:01:38 +1000 Subject: [PATCH] feat(connect): add --connect flag to connect to remote cluster machine without using config --- cmd/uncloud/machine/init.go | 2 +- cmd/uncloud/main.go | 45 ++++++++++++++++++++++++------- internal/cli/cli.go | 30 +++++++++++++++++++-- internal/cli/config/connection.go | 2 +- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/cmd/uncloud/machine/init.go b/cmd/uncloud/machine/init.go index 5666d2b6..a9555562 100644 --- a/cmd/uncloud/machine/init.go +++ b/cmd/uncloud/machine/init.go @@ -77,7 +77,7 @@ func NewInitCommand() *cobra.Command { ) cmd.Flags().StringVarP( &opts.sshKey, "ssh-key", "i", "", - "path to SSH private key for SSH remote login. (default ~/.ssh/id_*)", + "Path to SSH private key for SSH remote login. (default ~/.ssh/id_*)", ) cmd.Flags().StringVarP( &opts.cluster, "cluster", "c", "", diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 4d635484..8e3ccdb6 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -4,32 +4,53 @@ import ( "context" "fmt" "github.com/spf13/cobra" - "os" + "net/netip" "strings" "uncloud/cmd/uncloud/caddy" "uncloud/cmd/uncloud/dns" "uncloud/cmd/uncloud/machine" "uncloud/cmd/uncloud/service" "uncloud/internal/cli" + "uncloud/internal/cli/config" + "uncloud/internal/fs" ) +type globalOptions struct { + configPath string + connect string +} + func main() { - var configPath string + opts := globalOptions{} cmd := &cobra.Command{ Use: "uncloud", Short: "A CLI tool for managing Uncloud resources such as clusters, machines, and services.", SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if strings.HasPrefix(configPath, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("get user home directory to resolve %q: %w", configPath, err) + var conn *config.MachineConnection + if opts.connect != "" { + if strings.HasPrefix(opts.connect, "tcp://") { + addrPort, err := netip.ParseAddrPort(opts.connect[len("tcp://"):]) + if err != nil { + return fmt.Errorf("parse TCP address: %w", err) + } + conn = &config.MachineConnection{ + TCP: addrPort, + } + } else { + dest := opts.connect + if strings.HasPrefix(dest, "ssh://") { + dest = dest[len("ssh://"):] + } + conn = &config.MachineConnection{ + SSH: config.SSHDestination(dest), + } } - configPath = strings.Replace(configPath, "~", home, 1) } - uncli, err := cli.New(configPath) + configPath := fs.ExpandHomeDir(opts.configPath) + uncli, err := cli.New(configPath, conn) if err != nil { return fmt.Errorf("initialize CLI: %w", err) } @@ -37,9 +58,13 @@ func main() { return nil }, } + + cmd.PersistentFlags().StringVar(&opts.connect, "connect", "", + "Connect to a remote cluster machine without using the Uncloud configuration file.\n"+ + "Format: [ssh://]user@host[:port] or tcp://host:port") // TODO: allow to override using UNCLOUD_CONFIG env var. - cmd.PersistentFlags().StringVar(&configPath, "uncloud-config", "~/.config/uncloud/config.toml", - "path to the Uncloud configuration file.") + cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.toml", + "Path to the Uncloud configuration file.") _ = cmd.MarkPersistentFlagFilename("uncloud-config", "toml") cmd.AddCommand( diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 398812e9..d4af0a07 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,13 +23,22 @@ const defaultClusterName = "default" type CLI struct { config *config.Config + conn *config.MachineConnection } -func New(configPath string) (*CLI, error) { +// New creates a new CLI instance with the given config path or remote machine connection. +// If the connection is provided, the config is ignored for all operations which is useful for interacting with +// a cluster without creating a config. +func New(configPath string, conn *config.MachineConnection) (*CLI, error) { + if conn != nil { + return &CLI{conn: conn}, nil + } + cfg, err := config.NewFromFile(configPath) if err != nil { return nil, fmt.Errorf("read Uncloud config: %w", err) } + return &CLI{ config: cfg, }, nil @@ -53,7 +62,13 @@ func (cli *CLI) SetCurrentCluster(name string) error { return cli.config.Save() } +// ConnectCluster connects to a cluster using the given cluster name or the current cluster if not specified. +// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead. func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client.Client, error) { + if cli.conn != nil { + return connectCluster(ctx, *cli.conn) + } + if len(cli.config.Clusters) == 0 { return nil, errors.New( "no clusters found in the Uncloud config. " + @@ -88,6 +103,16 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client // TODO: iterate over all connections and try to connect to the cluster using the first successful connection. conn := cfg.Connections[0] + + c, err := connectCluster(ctx, conn) + if err != nil { + return nil, errors.New("no valid connection configuration found for the cluster") + } + + return c, nil +} + +func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) { if conn.SSH != "" { user, host, port, err := conn.SSH.Parse() if err != nil { @@ -106,7 +131,8 @@ func (cli *CLI) ConnectCluster(ctx context.Context, clusterName string) (*client } else if conn.TCP.IsValid() { return client.New(ctx, connector.NewTCPConnector(conn.TCP)) } - return nil, errors.New("no valid connection configuration found for the cluster") + + return nil, errors.New("connection configuration is invalid") } // InitCluster initialises a new cluster on a remote machine and returns a client to interact with the cluster. diff --git a/internal/cli/config/connection.go b/internal/cli/config/connection.go index 97273872..28415a9e 100644 --- a/internal/cli/config/connection.go +++ b/internal/cli/config/connection.go @@ -15,10 +15,10 @@ const ( type MachineConnection struct { SSH SSHDestination `toml:"ssh,omitempty"` + SSHKeyFile string `toml:"ssh_key_file,omitempty"` TCP netip.AddrPort `toml:"tcp,omitempty"` Host string `toml:"host,omitempty"` PublicKey secret.Secret `toml:"public_key,omitempty"` - SSHKeyFile string `toml:"ssh_key_file,omitempty"` } // SSHDestination represents an SSH destination string in the canonical form of "user@host:port".