mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat: try each cluster connection in order until one succeeds, add progress spinner
This commit is contained in:
+21
-31
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/internal/machine"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/sshexec"
|
||||
@@ -73,8 +71,18 @@ func (cli *CLI) SetCurrentContext(name string) error {
|
||||
// ConnectCluster connects to a cluster using the given context name or the current context 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, contextName string) (*client.Client, error) {
|
||||
return cli.ConnectClusterWithOptions(ctx, contextName, ConnectOptions{
|
||||
// Default to showing progress for CLI usage.
|
||||
ShowProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ConnectClusterWithOptions connects to a cluster using the given context name and options.
|
||||
// If the CLI was initialised with a machine connection, the config is ignored and the connection is used instead.
|
||||
// Options are useful when using the CLI as a library where you may want to disable visual feedback.
|
||||
func (cli *CLI) ConnectClusterWithOptions(ctx context.Context, contextName string, opts ConnectOptions) (*client.Client, error) {
|
||||
if cli.conn != nil {
|
||||
return connectCluster(ctx, *cli.conn)
|
||||
return ConnectCluster(ctx, *cli.conn, opts)
|
||||
}
|
||||
|
||||
if len(cli.Config.Contexts) == 0 {
|
||||
@@ -116,38 +124,20 @@ func (cli *CLI) ConnectCluster(ctx context.Context, contextName 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, fmt.Errorf("connect to cluster (context '%s'): %w", contextName, err)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
||||
// Try each connection in order until one succeeds.
|
||||
var lastErr error
|
||||
for _, conn := range cfg.Connections {
|
||||
c, err := ConnectCluster(ctx, conn, opts)
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: keyPath,
|
||||
}
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
return nil, fmt.Errorf("failed to connect to cluster context '%s': "+
|
||||
"all connections (%d) in the Uncloud config (%s) failed; last error: %w",
|
||||
contextName, len(cfg.Connections), cli.Config.Path(), lastErr)
|
||||
}
|
||||
|
||||
type InitClusterOptions struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
@@ -24,6 +25,15 @@ type MachineConnection struct {
|
||||
PublicKey secret.Secret `yaml:"public_key,omitempty"`
|
||||
}
|
||||
|
||||
func (c MachineConnection) String() string {
|
||||
if c.SSH != "" {
|
||||
return string(c.SSH)
|
||||
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||
}
|
||||
return "unknown connection"
|
||||
}
|
||||
|
||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||
// The default user "root" and port 22 can be omitted.
|
||||
type SSHDestination string
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/fs"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/psviderski/uncloud/pkg/client/connector"
|
||||
)
|
||||
|
||||
// ConnectOptions configures the behavior of cluster connection attempts.
|
||||
type ConnectOptions struct {
|
||||
// Whether to show connection progress spinner if stdout is a terminal or progress logs if not.
|
||||
ShowProgress bool
|
||||
}
|
||||
|
||||
func ConnectCluster(ctx context.Context, conn config.MachineConnection, opts ConnectOptions) (*client.Client, error) {
|
||||
if opts.ShowProgress {
|
||||
return connectClusterWithProgress(ctx, conn)
|
||||
}
|
||||
return connectCluster(ctx, conn)
|
||||
}
|
||||
|
||||
// connectClusterWithProgress connects to the cluster while displaying a progress spinner.
|
||||
// If the stdout is not a terminal, it falls back to simple progress logs to stderr.
|
||||
func connectClusterWithProgress(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
// If stdout is not a terminal, fall back to simple progress logs.
|
||||
if !IsStdoutTerminal() {
|
||||
fmt.Fprintln(os.Stderr, "Connecting to", conn.String())
|
||||
cli, err := connectCluster(ctx, conn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Connection failed:", err)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Connected to cluster.")
|
||||
}
|
||||
return cli, err
|
||||
}
|
||||
|
||||
// Run the connection TUI model.
|
||||
p := tea.NewProgram(newConnectModel(ctx, conn))
|
||||
model, err := p.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run connection TUI: %w", err)
|
||||
}
|
||||
|
||||
m := model.(connectModel)
|
||||
return m.result.client, m.result.err
|
||||
}
|
||||
|
||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||
if conn.SSH != "" {
|
||||
user, host, port, err := conn.SSH.Parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
||||
}
|
||||
|
||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||
|
||||
sshConfig := &connector.SSHConnectorConfig{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: keyPath,
|
||||
}
|
||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||
}
|
||||
|
||||
return nil, errors.New("connection configuration is invalid")
|
||||
}
|
||||
|
||||
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||
type connectModel struct {
|
||||
ctx context.Context
|
||||
conn config.MachineConnection
|
||||
spinner spinner.Model
|
||||
// showSpinner controls whether the spinner is visible (delayed to avoid flashing).
|
||||
showSpinner bool
|
||||
// done indicates whether the connection attempt has completed (successfully or with error).
|
||||
done bool
|
||||
// result holds the result of the connection attempt.
|
||||
result connectResultMsg
|
||||
}
|
||||
|
||||
type connectResultMsg struct {
|
||||
client *client.Client
|
||||
err error
|
||||
}
|
||||
|
||||
// showSpinnerMsg is sent after a delay to show the spinner.
|
||||
type showSpinnerMsg struct{}
|
||||
|
||||
func newConnectModel(ctx context.Context, conn config.MachineConnection) connectModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // the same yellow as in compose progress
|
||||
|
||||
return connectModel{
|
||||
ctx: ctx,
|
||||
conn: conn,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (m connectModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
m.connect(),
|
||||
m.delayShowSpinner(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m connectModel) connect() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cli, err := connectCluster(m.ctx, m.conn)
|
||||
return connectResultMsg{
|
||||
client: cli,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delayShowSpinner returns a command that sends a message to show the spinner after a delay.
|
||||
// This avoids flashing the spinner if the connection is fast.
|
||||
func (m connectModel) delayShowSpinner() tea.Cmd {
|
||||
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return showSpinnerMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m connectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
m.result.err = m.ctx.Err()
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
default:
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case connectResultMsg:
|
||||
m.result = msg
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
|
||||
case showSpinnerMsg:
|
||||
// Only show spinner if connection hasn't completed yet.
|
||||
if !m.done {
|
||||
m.showSpinner = true
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
m.result.err = fmt.Errorf("connection cancelled")
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m connectModel) View() string {
|
||||
// Don't show anything if done or spinner not yet visible.
|
||||
if m.done || !m.showSpinner {
|
||||
return ""
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("153"))
|
||||
return fmt.Sprintf("%s %s\n",
|
||||
m.spinner.View(),
|
||||
fmt.Sprintf("Connecting to %s", style.Render(m.conn.String())),
|
||||
)
|
||||
}
|
||||
@@ -31,3 +31,8 @@ func Confirm() (bool, error) {
|
||||
func IsStdinTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStdoutTerminal checks if the standard output is a terminal (TTY).
|
||||
func IsStdoutTerminal() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user