diff --git a/cmd/uc/context/create.go b/cmd/uc/context/create.go new file mode 100644 index 00000000..ed19298a --- /dev/null +++ b/cmd/uc/context/create.go @@ -0,0 +1,163 @@ +package context + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + + "github.com/goccy/go-yaml" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/config" + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/spf13/cobra" +) + +type createOptions struct { + context string + sshKey string + write bool +} + +func NewCreateCommand() *cobra.Command { + opts := createOptions{} + cmd := &cobra.Command{ + Use: "create [schema://]USER@HOST[:PORT]", + Short: "Create the cluster context to Uncloud configuration file by connecting to the remote machine.", + Long: `Create the cluster context, or add new machines to an existing cluster context. +This command adds or updates an (existing) context in your Uncloud config with machines that have a public IP address +configured. By default the context is printed to standard output, use -w to write it to the Uncloud config. + +Connection methods: + [ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required) + ssh+go://user@host - Use Go's built-in SSH library`, + Example: ` # Get the cluster context with default settings. + uc context create -w root@ + + # Add a new context named 'prod' in the Uncloud config (~/.config/uncloud/config.yaml). + uc context create -w root@ -c prod + + # Add a new context with a non-root user and custom SSH port and key. + uc context create -w ubuntu@:2222 -i ~/.ssh/mykey`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + + destination := args[0] + useSSHGo := strings.HasPrefix(destination, "ssh+go://") + destination = strings.TrimPrefix(destination, "ssh+go://") + destination = strings.TrimPrefix(destination, "ssh+cli://") + destination = strings.TrimPrefix(destination, "ssh://") + + if _, _, _, err := config.SSHDestination(destination).Parse(); err != nil { + return fmt.Errorf("parse remote machine: %w", err) + } + + conn := config.MachineConnection{} + if useSSHGo { + conn.SSHGo = config.SSHDestination(destination) + } else { + conn.SSH = config.SSHDestination(destination) + } + + return createContext(cmd.Context(), uncli, conn, opts) + }, + } + + cmd.Flags().StringVarP( + &opts.context, "context", "c", cli.DefaultContextName, + "Name of the new context to be created in the Uncloud config to manage the cluster.", + ) + cmd.Flags().StringVarP( + &opts.sshKey, "ssh-key", "i", "", + fmt.Sprintf("Path to SSH private key for remote login (if not already added to SSH agent). (default %q)", + cli.DefaultSSHKeyPath), + ) + cmd.Flags().BoolVarP( + &opts.write, "write", "w", false, + "Write a new Uncloud config, by default the config is only printed to standard output.", + ) + + return cmd +} + +func createContext(ctx context.Context, uncli *cli.CLI, conn config.MachineConnection, opts createOptions) error { + contextName, err := uncli.NewContextName(opts.context) + if err != nil { + return err + } + + conn.SSHKeyFile = opts.sshKey + client, err := cli.ConnectCluster(ctx, conn, cli.ConnectOptions{ShowProgress: true}) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + machines, err := client.ListMachines(ctx, nil) + if err != nil { + return fmt.Errorf("list machines: %w", err) + } + machines = slices.DeleteFunc(machines, func(m *pb.MachineMember) bool { + return m.Machine.GetPublicIp() == nil + }) + + // Figure out if one of the machines is already in a context, and add the remaining there. Otherwise we + // create a new context with the name we got from the command line. + for name, context := range uncli.Config.Contexts { + for _, conn := range context.Connections { + for _, machine := range machines { + if machine.Machine.Id == conn.MachineID { + contextName = name + break + } + } + } + } + + var ( + user string + port int + ) + + if conn.SSH != "" { + user, _, port, _ = conn.SSH.Parse() + } + if conn.SSHGo != "" { + user, _, port, _ = conn.SSHGo.Parse() + } + + connCfg := []config.MachineConnection{} + for _, machine := range machines { + addr, _ := machine.Machine.PublicIp.ToAddr() + dest := config.NewSSHDestination(user, addr.String(), port) + + machineConn := config.MachineConnection{MachineID: machine.Machine.Id} + if conn.SSH != "" { + machineConn.SSH = dest + } + if conn.SSHGo != "" { + machineConn.SSHGo = dest + } + connCfg = append(connCfg, machineConn) + } + + if !opts.write { + encoder := yaml.NewEncoder(os.Stdout, yaml.Indent(2), yaml.IndentSequence(true)) + contexts := map[string]*config.Context{ + contextName: { + Connections: connCfg, + }, + } + + encoder.Encode(contexts) + return nil + } + + uncli.Config.Contexts[contextName].Connections = connCfg + if err = uncli.Config.Save(); err != nil { + return fmt.Errorf("save config: %w", err) + } + return nil +} diff --git a/cmd/uc/context/root.go b/cmd/uc/context/root.go index dda22c2e..0744b004 100644 --- a/cmd/uc/context/root.go +++ b/cmd/uc/context/root.go @@ -21,6 +21,7 @@ func NewRootCommand() *cobra.Command { NewListCommand(), NewUseCommand(), NewConnectionCommand(), + NewCreateCommand(), NewShowCommand(), ) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0ab60d04..8f55d1be 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -191,7 +191,7 @@ func (cli *CLI) InitCluster(ctx context.Context, opts InitClusterOptions) (*clie } func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) (*client.Client, error) { - contextName, err := cli.newContextName(opts.Context) + contextName, err := cli.NewContextName(opts.Context) if err != nil { return nil, err } @@ -289,10 +289,10 @@ func (cli *CLI) initRemoteMachine(ctx context.Context, opts InitClusterOptions) return machineClient, nil } -// newContextName returns a unique name for a new cluster context. If the provided name is not DefaultContextName, +// NewContextName returns a unique name for a new cluster context. If the provided name is not DefaultContextName, // and it's already taken, an error is returned. If the name is not provided or is DefaultContextName, the first // available name "default[-N]" is returned. -func (cli *CLI) newContextName(name string) (string, error) { +func (cli *CLI) NewContextName(name string) (string, error) { if name == "" { name = DefaultContextName } diff --git a/website/docs/9-cli-reference/uc_ctx.md b/website/docs/9-cli-reference/uc_ctx.md index f421543f..b9f2c6a3 100644 --- a/website/docs/9-cli-reference/uc_ctx.md +++ b/website/docs/9-cli-reference/uc_ctx.md @@ -25,6 +25,7 @@ uc ctx [flags] * [uc](uc.md) - A CLI tool for managing Uncloud resources such as machines, services, and volumes. * [uc ctx connection](uc_ctx_connection.md) - Choose a new default connection for the current context. +* [uc ctx create](uc_ctx_create.md) - Create the cluster context to Uncloud configuration file by connecting to the remote machine. * [uc ctx ls](uc_ctx_ls.md) - List available cluster contexts. * [uc ctx show](uc_ctx_show.md) - Show current cluster context. * [uc ctx use](uc_ctx_use.md) - Switch to a different cluster context. diff --git a/website/docs/9-cli-reference/uc_ctx_create.md b/website/docs/9-cli-reference/uc_ctx_create.md new file mode 100644 index 00000000..57b7808e --- /dev/null +++ b/website/docs/9-cli-reference/uc_ctx_create.md @@ -0,0 +1,52 @@ +# uc ctx create + +Create the cluster context to Uncloud configuration file by connecting to the remote machine. + +## Synopsis + +Create the cluster context, or add new machines to an existing cluster context. +This command adds or updates an (existing) context in your Uncloud config with machines that have a public IP address +configured. By default the context is printed to standard output, use -w to write it to the Uncloud config. + +Connection methods: + [ssh://]user@host - Use system 'ssh' command with full SSH config support (default, no prefix required) + ssh+go://user@host - Use Go's built-in SSH library + +``` +uc ctx create [schema://]USER@HOST[:PORT] [flags] +``` + +## Examples + +``` + # Get the cluster context with default settings. + uc context create -w root@ + + # Add a new context named 'prod' in the Uncloud config (~/.config/uncloud/config.yaml). + uc context create -w root@ -c prod + + # Add a new context with a non-root user and custom SSH port and key. + uc context create -w ubuntu@:2222 -i ~/.ssh/mykey +``` + +## Options + +``` + -c, --context string Name of the new context to be created in the Uncloud config to manage the cluster. (default "default") + -h, --help help for create + -i, --ssh-key string Path to SSH private key for remote login (if not already added to SSH agent). (default "~/.ssh/id_ed25519") + -w, --write Write a new Uncloud config, by default the config is only printed to standard output. +``` + +## Options inherited from parent commands + +``` + --connect string Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT] + Format: [ssh://]user@host[:port], ssh+go://user@host[:port], tcp://host:port, or unix:///path/to/uncloud.sock + --uncloud-config string Path to the Uncloud configuration file. [$UNCLOUD_CONFIG] (default "~/.config/uncloud/config.yaml") +``` + +## See also + +* [uc ctx](uc_ctx.md) - Switch between different cluster contexts. Contains subcommands to manage contexts. +