uncloud CLI tool and 'machine add' command to bootstrap a new cluster

This commit is contained in:
Pavel Sviderski
2024-08-24 20:14:47 +10:00
parent 3f13f6c0e6
commit d369884f7a
9 changed files with 425 additions and 0 deletions
+20
View File
@@ -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
}
+171
View File
@@ -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
}
+9
View File
@@ -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"`
}
+63
View File
@@ -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()
}
+8
View File
@@ -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"`
}
+11
View File
@@ -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}
}