From d369884f7a30fd92276f9a233ce600d7b568c075 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Sat, 24 Aug 2024 20:14:47 +1000 Subject: [PATCH] uncloud CLI tool and 'machine add' command to bootstrap a new cluster --- cmd/uncloud/machine/add.go | 81 ++++++++++++++++ cmd/uncloud/machine/root.go | 16 +++ cmd/uncloud/main.go | 46 +++++++++ internal/cli/cli.go | 20 ++++ internal/cli/cluster.go | 171 +++++++++++++++++++++++++++++++++ internal/cli/config/cluster.go | 9 ++ internal/cli/config/config.go | 63 ++++++++++++ internal/cli/config/machine.go | 8 ++ internal/cli/machine.go | 11 +++ 9 files changed, 425 insertions(+) create mode 100644 cmd/uncloud/machine/add.go create mode 100644 cmd/uncloud/machine/root.go create mode 100644 cmd/uncloud/main.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cluster.go create mode 100644 internal/cli/config/cluster.go create mode 100644 internal/cli/config/config.go create mode 100644 internal/cli/config/machine.go create mode 100644 internal/cli/machine.go diff --git a/cmd/uncloud/machine/add.go b/cmd/uncloud/machine/add.go new file mode 100644 index 00000000..158becaf --- /dev/null +++ b/cmd/uncloud/machine/add.go @@ -0,0 +1,81 @@ +package machine + +import ( + "context" + "errors" + "fmt" + "github.com/spf13/cobra" + "uncloud/internal/cli" +) + +type addOptions struct { + name string + user string + port int + sshKey string + cluster string +} + +func NewAddCommand() *cobra.Command { + opts := addOptions{} + cmd := &cobra.Command{ + Use: "add HOST", + Short: "Add a new machine to a cluster.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return add(cmd.Context(), uncli, args[0], opts) + }, + } + cmd.Flags().StringVarP(&opts.name, "name", "n", "", "Assign a name to the machine") + cmd.Flags().StringVarP(&opts.user, "user", "u", "root", "Username for SSH remote login") + cmd.Flags().IntVarP(&opts.port, "port", "p", 22, "Port for SSH remote login") + cmd.Flags().StringVarP(&opts.sshKey, "ssh-key", "i", "", + "path to SSH private key for SSH remote login (default ~/.ssh/id_*)") + cmd.Flags().StringVarP(&opts.cluster, "cluster", "c", "", + "Name of the cluster to add the machine to (default is the current cluster)") + return cmd +} + +func add(ctx context.Context, uncli *cli.CLI, host string, opts addOptions) error { + var ( + cluster *cli.Cluster + err error + ) + if opts.cluster == "" { + // If the cluster is not specified, use the current cluster. If there are no clusters, create a default one. + cluster, err = uncli.GetCurrentCluster() + if err != nil { + if errors.Is(err, cli.ErrNotFound) { + // Do not create a default cluster if there are already clusters but the current cluster is not set. + clusters, cErr := uncli.ListClusters() + if cErr != nil { + return fmt.Errorf("list clusters: %w", cErr) + } + if len(clusters) > 0 { + return errors.New("the current cluster is not set in the Uncloud config. " + + "Please specify a cluster with the --cluster flag or set current_cluster in the config") + } + + cluster, err = uncli.CreateDefaultCluster() + if err != nil { + return fmt.Errorf("create default cluster: %w", err) + } + } else { + return fmt.Errorf("get current cluster: %w", err) + } + } + } else { + cluster, err = uncli.GetCluster(opts.cluster) + if err != nil { + return fmt.Errorf("get cluster %q: %w", opts.cluster, err) + } + } + + name, err := cluster.AddMachine(ctx, opts.name, opts.user, host, opts.port, opts.sshKey) + if err != nil { + return fmt.Errorf("add machine to cluster %q: %w", cluster.Name, err) + } + fmt.Printf("Machine %q added to cluster %q\n", name, cluster.Name) + return nil +} diff --git a/cmd/uncloud/machine/root.go b/cmd/uncloud/machine/root.go new file mode 100644 index 00000000..bb66e23d --- /dev/null +++ b/cmd/uncloud/machine/root.go @@ -0,0 +1,16 @@ +package machine + +import ( + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "machine", + Short: "Manage machines in an Uncloud cluster.", + } + cmd.AddCommand( + NewAddCommand(), + ) + return cmd +} diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go new file mode 100644 index 00000000..c1ee2ba5 --- /dev/null +++ b/cmd/uncloud/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "github.com/spf13/cobra" + "os" + "strings" + "uncloud/cmd/uncloud/machine" + "uncloud/internal/cli" +) + +func main() { + var configPath string + 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) + } + configPath = strings.Replace(configPath, "~", home, 1) + } + + uncli, err := cli.New(configPath) + if err != nil { + return fmt.Errorf("initialize CLI: %w", err) + } + cmd.SetContext(context.WithValue(cmd.Context(), "cli", uncli)) + return nil + }, + } + // 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.MarkPersistentFlagFilename("uncloud-config", "toml") + + cmd.AddCommand( + machine.NewRootCommand(), + ) + cobra.CheckErr(cmd.Execute()) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 00000000..d1fdf1ad --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,20 @@ +package cli + +import ( + "fmt" + "uncloud/internal/cli/config" +) + +type CLI struct { + config *config.Config +} + +func New(configPath string) (*CLI, error) { + cfg, err := config.NewFromFile(configPath) + if err != nil { + return nil, fmt.Errorf("read Uncloud config: %w", err) + } + return &CLI{ + config: cfg, + }, nil +} diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go new file mode 100644 index 00000000..55671fed --- /dev/null +++ b/internal/cli/cluster.go @@ -0,0 +1,171 @@ +package cli + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "net/netip" + "uncloud/internal/cli/config" + "uncloud/internal/cmdexec" + "uncloud/internal/machine" + "uncloud/internal/secret" +) + +var ( + ErrNotFound = errors.New("not found") +) + +type Cluster struct { + Name string + privateKey ed25519.PrivateKey + + config *config.Config +} + +func (c *Cluster) toConfig() *config.Cluster { + return &config.Cluster{ + Name: c.Name, + Secret: c.privateKey.Seed(), + } +} + +func (cli *CLI) CreateCluster(name string, privateKey ed25519.PrivateKey) (*Cluster, error) { + if _, ok := cli.config.Clusters[name]; ok { + return nil, fmt.Errorf("cluster %q already exists", name) + } + if privateKey == nil { + var err error + _, privateKey, err = ed25519.GenerateKey(nil) + if err != nil { + return nil, fmt.Errorf("generate cluster secret: %w", err) + } + } + + c := &Cluster{ + Name: name, + privateKey: privateKey, + config: cli.config, + } + cli.config.Clusters[name] = c.toConfig() + if err := cli.config.Save(); err != nil { + return nil, err + } + + return c, nil +} + +func (cli *CLI) CreateDefaultCluster() (*Cluster, error) { + c, err := cli.CreateCluster("default", nil) + if err != nil { + return nil, err + } + if err = cli.SetCurrentCluster(c.Name); err != nil { + return nil, err + } + return c, nil +} + +func (cli *CLI) GetCluster(name string) (*Cluster, error) { + clusterCfg, ok := cli.config.Clusters[name] + if !ok { + return nil, ErrNotFound + } + privateKey, err := privateKeyFromSecret(clusterCfg.Secret) + if err != nil { + return nil, err + } + + return &Cluster{ + Name: name, + privateKey: privateKey, + config: cli.config, + }, nil +} + +func (cli *CLI) GetCurrentCluster() (*Cluster, error) { + return cli.GetCluster(cli.config.CurrentCluster) +} + +func (cli *CLI) SetCurrentCluster(name string) error { + if _, ok := cli.config.Clusters[name]; !ok { + return ErrNotFound + } + cli.config.CurrentCluster = name + return cli.config.Save() +} + +func (cli *CLI) ListClusters() ([]*Cluster, error) { + var clusters []*Cluster + for name := range cli.config.Clusters { + c, err := cli.GetCluster(name) + if err != nil { + return nil, fmt.Errorf("get cluster %q: %w", name, err) + } + clusters = append(clusters, c) + } + return clusters, nil +} + +func (c *Cluster) AddMachine(ctx context.Context, name, user, host string, port int, sshKeyPath string) (string, error) { + exec, err := cmdexec.Connect(user, host, port, sshKeyPath) + if err != nil { + return "", fmt.Errorf("SSH login to %s@%s:%d: %w", user, host, port, err) + } + defer func() { + _ = exec.Close() + }() + + mcfg, err := machine.NewBootstrapConfig(name, netip.Prefix{}) + if err != nil { + return "", fmt.Errorf("generate machine bootstrap config: %w", err) + } + + sudoPrefix := "" + if user != "root" { + sudoPrefix = "sudo" + } + + _, err = exec.Run(ctx, cmdexec.QuoteCommand(sudoPrefix, "mkdir", "-p", machine.DefaultDataDir)) + if err != nil { + return "", fmt.Errorf("create data directory %q: %w", machine.DefaultDataDir, err) + } + + // TODO: Check if the machine is already provisioned and ask the user to reset it first. + // Write the machine config to /var/lib/uncloud/machine.json by piping the JSON data to the file. + mcfgData, err := mcfg.Encode() + if err != nil { + return "", fmt.Errorf("encode machine config: %w", err) + } + mcfgPath := cmdexec.Quote(machine.ConfigPath(machine.DefaultDataDir)) + createFileCmd := fmt.Sprintf("%s touch %s && %s chmod 600 %s", sudoPrefix, mcfgPath, sudoPrefix, mcfgPath) + _, err = exec.Run(ctx, fmt.Sprintf("%s && echo %s | %s tee %s > /dev/null", + createFileCmd, cmdexec.Quote(string(mcfgData)), sudoPrefix, mcfgPath)) + if err != nil { + return "", fmt.Errorf("write machine config to %q: %w", mcfgPath, err) + } + + // TODO: download and install the latest uncloudd binary by running the install shell script from GitHub. + // For now upload the binary using scp manually. + + connConfig := config.MachineConnection{ + User: user, + Host: host, + Port: port, + SSHKey: sshKeyPath, + } + c.config.Clusters[c.Name].Machines = append(c.config.Clusters[c.Name].Machines, connConfig) + if err = c.config.Save(); err != nil { + return "", fmt.Errorf("save config: %w", err) + } + + return mcfg.Name, nil +} + +func privateKeyFromSecret(s secret.Secret) (ed25519.PrivateKey, error) { + // Cluster secret in the config is a hex-encoded private key seed. + if len(s) != ed25519.SeedSize { + return nil, fmt.Errorf("invalid cluster secret length") + } + return ed25519.NewKeyFromSeed(s), nil +} diff --git a/internal/cli/config/cluster.go b/internal/cli/config/cluster.go new file mode 100644 index 00000000..99b9a663 --- /dev/null +++ b/internal/cli/config/cluster.go @@ -0,0 +1,9 @@ +package config + +import "uncloud/internal/secret" + +type Cluster struct { + Name string `toml:"-"` + Machines []MachineConnection `toml:"machines"` + Secret secret.Secret `toml:"secret"` +} diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go new file mode 100644 index 00000000..942aa988 --- /dev/null +++ b/internal/cli/config/config.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + "github.com/BurntSushi/toml" + "os" + "path/filepath" +) + +type Config struct { + Clusters map[string]*Cluster `toml:"clusters"` + CurrentCluster string `toml:"current_cluster"` + + // path is the file path config is read from. + path string +} + +func NewFromFile(path string) (*Config, error) { + _, err := os.Stat(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("check file permissions %q: %w", path, err) + } + c := &Config{ + Clusters: map[string]*Cluster{}, + path: path, + } + if os.IsNotExist(err) { + return c, nil + } + + if err = c.Read(); err != nil { + return nil, err + } + return c, nil +} + +func (c *Config) Read() error { + _, err := toml.DecodeFile(c.path, c) + if err != nil { + return fmt.Errorf("read config file %q: %w", c.path, err) + } + return nil +} + +func (c *Config) Save() error { + dir, _ := filepath.Split(c.path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("create config directory %q: %w", dir, err) + } + + f, err := os.OpenFile(c.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("write config file %q: %w", c.path, err) + } + + encoder := toml.NewEncoder(f) + encoder.Indent = "" + if err = encoder.Encode(c); err != nil { + _ = f.Close() + return fmt.Errorf("encode config file %q: %w", c.path, err) + } + return f.Close() +} diff --git a/internal/cli/config/machine.go b/internal/cli/config/machine.go new file mode 100644 index 00000000..96a81b09 --- /dev/null +++ b/internal/cli/config/machine.go @@ -0,0 +1,8 @@ +package config + +type MachineConnection struct { + User string `toml:"user,omitempty"` + Host string `toml:"host"` + Port int `toml:"port"` + SSHKey string `toml:"ssh_key,omitempty"` +} diff --git a/internal/cli/machine.go b/internal/cli/machine.go new file mode 100644 index 00000000..731a9e89 --- /dev/null +++ b/internal/cli/machine.go @@ -0,0 +1,11 @@ +package cli + +import "uncloud/internal/cli/config" + +type Machine struct { + connConfig config.MachineConnection +} + +func NewMachine(connConfig config.MachineConnection) *Machine { + return &Machine{connConfig: connConfig} +}