mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
uncloud CLI tool and 'machine add' command to bootstrap a new cluster
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user