mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
refactor(cli): move cmd/uncloud to cmd/uc, change the artifact name uncloud_* -> uc_*
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uc/caddy"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type addOptions struct {
|
||||
name string
|
||||
noCaddy bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
wgEndpoints []string
|
||||
wgPort int
|
||||
wgMTU int
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewAddCommand() *cobra.Command {
|
||||
opts := addOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [USER@]HOST[:PORT]",
|
||||
Short: "Add a remote machine to a cluster.",
|
||||
Long: `Add a new machine to an existing Uncloud cluster.
|
||||
|
||||
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`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
// Determine connection mode and strip scheme.
|
||||
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://")
|
||||
|
||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine := &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHGo: useSSHGo,
|
||||
}
|
||||
|
||||
return add(cmd.Context(), uncli, remoteMachine, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.name, "name", "n", "",
|
||||
"Assign a name to the machine. (default is the machine's hostname)")
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
||||
)
|
||||
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().StringVar(
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
"WireGuard endpoint address that other machines in the cluster should use to establish "+
|
||||
"WireGuard connections\n"+
|
||||
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
|
||||
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"Defaults to the auto-detected public and routable machine IPs.",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgMTU, "wg-mtu", 0,
|
||||
"MTU of the WireGuard network interface on the machine. (default auto-detects the optimal value)",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgPort, "wg-port", network.DefaultWireGuardPort,
|
||||
"UDP port WireGuard listens on for incoming connections from other machines.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts addOptions) error {
|
||||
var publicIP *netip.Addr
|
||||
switch opts.publicIP {
|
||||
case "auto":
|
||||
publicIP = &netip.Addr{}
|
||||
case "", PublicIPNone:
|
||||
publicIP = nil
|
||||
default:
|
||||
ip, err := netip.ParseAddr(opts.publicIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse public IP: %w", err)
|
||||
}
|
||||
publicIP = &ip
|
||||
}
|
||||
|
||||
if opts.wgPort < 1 || opts.wgPort > 65535 {
|
||||
return fmt.Errorf("invalid WireGuard port %d: must be between 1 and 65535", opts.wgPort)
|
||||
}
|
||||
if opts.wgMTU != 0 && (opts.wgMTU < network.MinWireGuardMTU || opts.wgMTU > 65535) {
|
||||
return fmt.Errorf("invalid WireGuard MTU %d: must be 0 (auto-detect) or between %d and 65535",
|
||||
opts.wgMTU, network.MinWireGuardMTU)
|
||||
}
|
||||
addOpts := cli.AddMachineOptions{
|
||||
MachineName: opts.name,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
WireguardMTU: opts.wgMTU,
|
||||
WireguardPort: opts.wgPort,
|
||||
AutoConfirm: opts.yes,
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
addOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
clusterClient, machineClient, err := uncli.AddMachine(ctx, addOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clusterClient.Close()
|
||||
defer machineClient.Close()
|
||||
|
||||
if opts.noCaddy {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait for the cluster to be initialised on the machine to be able to deploy the Caddy service.
|
||||
err = tui.RunSpinner(ctx, "Waiting for the machine to join the cluster...", func(ctx context.Context) error {
|
||||
return machineClient.WaitClusterReady(ctx, 5*time.Minute)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for machine to join the cluster: %w", err)
|
||||
}
|
||||
fmt.Println("Machine joined the cluster.")
|
||||
|
||||
// TODO: scale the existing Caddy service to the new machine instead of running a new deployment
|
||||
// that may cause a small downtime.
|
||||
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
|
||||
// use the deployed image version.
|
||||
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
|
||||
// issues accessing the Machine API of existing machines in the cluster.
|
||||
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
|
||||
caddyImage := ""
|
||||
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNotFound) {
|
||||
// Caddy service is not deployed.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inspect caddy service: %w", err)
|
||||
}
|
||||
caddyImage = caddySvc.Containers[0].Container.Config.Image
|
||||
// Find the latest created container and use its image.
|
||||
var latestCreated time.Time
|
||||
for _, c := range caddySvc.Containers[1:] {
|
||||
created, err := time.Parse(time.RFC3339Nano, c.Container.Created)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if created.After(latestCreated) {
|
||||
latestCreated = created
|
||||
caddyImage = c.Container.Config.Image
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Preparing Caddy deployment...")
|
||||
d, err := clusterClient.NewCaddyDeployment(caddyImage, "", api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
plan, err := d.Plan(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
if len(plan.Operations) == 0 {
|
||||
fmt.Printf("%s service is up to date.\n", client.CaddyServiceName)
|
||||
} else {
|
||||
fmt.Println(tui.Bold.Underline(true).Render("Deployment plan"))
|
||||
fmt.Println()
|
||||
fmt.Print(plan.Format())
|
||||
|
||||
summary := plan.FormatSummary()
|
||||
fmt.Println(tui.Faint.Render(strings.Repeat("─", lipgloss.Width(summary))))
|
||||
fmt.Println(summary)
|
||||
fmt.Println()
|
||||
|
||||
if !opts.yes {
|
||||
confirmed, err := tui.Confirm("Proceed with deployment?")
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm deployment: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
return cli.Cancelled("Caddy deploy cancelled. The machine has been added to the cluster.")
|
||||
}
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if _, err = d.Run(ctx); err != nil {
|
||||
return fmt.Errorf("deploy caddy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s (%s mode)", d.Spec.Name, d.Spec.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return caddy.UpdateDomainRecords(ctx, machineClient, uncli.ProgressOut())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package machine
|
||||
|
||||
const (
|
||||
// PublicIPNone is the value used to indicate removal of public IP
|
||||
PublicIPNone = "none"
|
||||
)
|
||||
@@ -0,0 +1,270 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/psviderski/uncloud/cmd/uc/caddy"
|
||||
"github.com/psviderski/uncloud/cmd/uc/dns"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/config"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/cluster"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type initOptions struct {
|
||||
context string
|
||||
dnsEndpoint string
|
||||
name string
|
||||
network string
|
||||
noCaddy bool
|
||||
noDNS bool
|
||||
noInstall bool
|
||||
publicIP string
|
||||
sshKey string
|
||||
version string
|
||||
wgEndpoints []string
|
||||
wgPort int
|
||||
wgMTU int
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewInitCommand() *cobra.Command {
|
||||
opts := initOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init [schema://]USER@HOST[:PORT]",
|
||||
Short: "Initialise a new cluster with a remote machine as the first member.",
|
||||
Long: `Initialise a new cluster by setting up a remote machine as the first member.
|
||||
This command creates a new context in your Uncloud config to manage the cluster.
|
||||
|
||||
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: ` # Initialise a new cluster with default settings.
|
||||
uc machine init root@<your-server-ip>
|
||||
|
||||
# Initialise with a context name 'prod' in the Uncloud config (~/.config/uncloud/config.yaml) and machine name 'vps1'.
|
||||
uc machine init root@<your-server-ip> -c prod -n vps1
|
||||
|
||||
# Initialise with a non-root user and custom SSH port and key.
|
||||
uc machine init ubuntu@<your-server-ip>:2222 -i ~/.ssh/mykey
|
||||
|
||||
# Initialise without Caddy (no reverse proxy) and without an automatically managed domain name (xxxxxx.uncld.dev).
|
||||
# You can deploy Caddy with 'uc caddy deploy' and reserve a domain with 'uc dns reserve' later.
|
||||
uc machine init root@<your-server-ip> --no-caddy --no-dns`,
|
||||
// TODO: support initialising a cluster on the local machine.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
|
||||
var remoteMachine *cli.RemoteMachine
|
||||
if len(args) > 0 {
|
||||
// Determine connection mode and strip scheme.
|
||||
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://")
|
||||
|
||||
user, host, port, err := config.SSHDestination(destination).Parse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse remote machine: %w", err)
|
||||
}
|
||||
remoteMachine = &cli.RemoteMachine{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
KeyPath: opts.sshKey,
|
||||
UseSSHGo: useSSHGo,
|
||||
}
|
||||
}
|
||||
|
||||
return initCluster(cmd.Context(), uncli, remoteMachine, 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().StringVar(&opts.dnsEndpoint, "dns-endpoint", dns.DefaultUncloudDNSAPIEndpoint,
|
||||
"API endpoint for the Uncloud DNS service.")
|
||||
cmd.Flags().StringVarP(
|
||||
&opts.name, "name", "n", "",
|
||||
"Assign a name to the machine. (default is the machine's hostname)",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.network, "network", cluster.DefaultNetwork.String(),
|
||||
"IPv4 network CIDR to use for machines and services.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noCaddy, "no-caddy", false,
|
||||
"Don't deploy Caddy reverse proxy service to the machine. You can deploy it later with 'uc caddy deploy'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noDNS, "no-dns", false,
|
||||
"Don't reserve a cluster domain in Uncloud DNS. You can reserve it later with 'uc dns reserve'.",
|
||||
)
|
||||
cmd.Flags().BoolVar(
|
||||
&opts.noInstall, "no-install", false,
|
||||
"Skip installation of Docker, Uncloud daemon, and dependencies on the machine. "+
|
||||
"Assumes they're already installed and running.",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "auto",
|
||||
"Public IP address of the machine for ingress configuration. Use 'auto' for automatic detection, "+
|
||||
fmt.Sprintf("blank '' or '%s' to disable ingress on this machine, or specify an IP address.", PublicIPNone),
|
||||
)
|
||||
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().StringVar(
|
||||
&opts.version, "version", "latest",
|
||||
"Version of the Uncloud daemon to install on the machine.",
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
"WireGuard endpoint address that other machines in the cluster should use to establish "+
|
||||
"WireGuard connections\n"+
|
||||
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
|
||||
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is the value of --wg-port if omitted.\n"+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.\n"+
|
||||
"Defaults to the auto-detected public and routable machine IPs.",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgMTU, "wg-mtu", 0,
|
||||
"MTU of the WireGuard network interface on the machine. (default auto-detects the optimal value)",
|
||||
)
|
||||
cmd.Flags().IntVar(
|
||||
&opts.wgPort, "wg-port", network.DefaultWireGuardPort,
|
||||
"UDP port WireGuard listens on for incoming connections from other machines.",
|
||||
)
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Auto-confirm prompts (e.g., resetting an already initialised machine).\n"+
|
||||
"Should be explicitly set when running non-interactively, e.g., in CI/CD pipelines. [$UNCLOUD_AUTO_CONFIRM]")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initCluster(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine, opts initOptions) error {
|
||||
if uncli.Config == nil {
|
||||
// Config is nil when connecting directly to a remote machine (--connect) without using Uncloud config
|
||||
// or when being logged in on a machine and using the uncloud socket directly.
|
||||
return fmt.Errorf(
|
||||
"do not use --connect when initialising a new cluster: --connect is for overriding the connection " +
|
||||
"to an existing cluster, but 'machine init' creates a new one and writes the new cluster context " +
|
||||
"to the Uncloud config file (--uncloud-config), when logged in on a cluster machine, 'machine init' " +
|
||||
"is not supported")
|
||||
}
|
||||
|
||||
netPrefix, err := netip.ParsePrefix(opts.network)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse network CIDR: %w", err)
|
||||
}
|
||||
|
||||
var publicIP *netip.Addr
|
||||
switch opts.publicIP {
|
||||
case "auto":
|
||||
publicIP = &netip.Addr{}
|
||||
case "", PublicIPNone:
|
||||
publicIP = nil
|
||||
default:
|
||||
ip, err := netip.ParseAddr(opts.publicIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse public IP: %w", err)
|
||||
}
|
||||
publicIP = &ip
|
||||
}
|
||||
if opts.wgPort < 1 || opts.wgPort > 65535 {
|
||||
return fmt.Errorf("invalid WireGuard port %d: must be between 1 and 65535", opts.wgPort)
|
||||
}
|
||||
if opts.wgMTU != 0 && (opts.wgMTU < network.MinWireGuardMTU || opts.wgMTU > 65535) {
|
||||
return fmt.Errorf("invalid WireGuard MTU %d: must be 0 (auto-detect) or between %d and 65535",
|
||||
opts.wgMTU, network.MinWireGuardMTU)
|
||||
}
|
||||
initOpts := cli.InitClusterOptions{
|
||||
Context: opts.context,
|
||||
MachineName: opts.name,
|
||||
Network: netPrefix,
|
||||
PublicIP: publicIP,
|
||||
RemoteMachine: remoteMachine,
|
||||
SkipInstall: opts.noInstall,
|
||||
Version: opts.version,
|
||||
WireguardMTU: opts.wgMTU,
|
||||
WireguardPort: opts.wgPort,
|
||||
AutoConfirm: opts.yes,
|
||||
}
|
||||
if len(opts.wgEndpoints) > 0 {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded, uint16(opts.wgPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse WireGuard endpoint (--wg-endpoint): %w", err)
|
||||
}
|
||||
initOpts.WireguardEndpoints = endpoints
|
||||
}
|
||||
|
||||
client, err := uncli.InitCluster(ctx, initOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Since the cluster API needs a few moments to become ready after cluster initialisation,
|
||||
// we keep the user informed during this wait. We wait here even if no Caddy or DNS is requested
|
||||
// as the cluster needs to be ready so that commands such as 'uc machine ls' work immediately after init.
|
||||
err = tui.RunSpinner(ctx, "Waiting for the cluster to be ready...", func(ctx context.Context) error {
|
||||
return client.WaitClusterReady(ctx, 1*time.Minute)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for cluster to be ready: %w", err)
|
||||
}
|
||||
fmt.Println("Cluster is ready.")
|
||||
|
||||
if opts.noCaddy && opts.noDNS {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if !opts.noDNS {
|
||||
domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{Endpoint: opts.dnsEndpoint})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reserve cluster domain in Uncloud DNS: %w", err)
|
||||
}
|
||||
fmt.Printf("Reserved cluster domain: %s\n", domain.Name)
|
||||
}
|
||||
|
||||
if !opts.noCaddy {
|
||||
d, err := client.NewCaddyDeployment("", "", api.Placement{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create caddy deployment: %w", err)
|
||||
}
|
||||
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
if _, err = d.Run(ctx); err != nil {
|
||||
return fmt.Errorf("deploy caddy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, uncli.ProgressOut(), fmt.Sprintf("Deploying service %s", d.Spec.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return caddy.UpdateDomainRecords(ctx, client, uncli.ProgressOut())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/logs"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/psviderski/uncloud/pkg/client"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewLogsCommand() *cobra.Command {
|
||||
var options logs.Options
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [SERVICE...]",
|
||||
Aliases: []string{"log"},
|
||||
Short: "View system service logs.",
|
||||
Long: `View logs from the specified system service(s) across all machines in the cluster.
|
||||
Use -m to restrict to specific machines.
|
||||
|
||||
Supported services:
|
||||
corrosion the Corrosion distributed state store
|
||||
docker the Docker daemon
|
||||
uncloud the Uncloud daemon
|
||||
|
||||
If no services are specified, streams logs from the uncloud service.`,
|
||||
Example: ` # View recent logs for the uncloud service.
|
||||
uc machine logs
|
||||
uc machine logs uncloud
|
||||
|
||||
# Stream logs in real-time (follow mode).
|
||||
uc machine logs -f uncloud
|
||||
|
||||
# View logs from multiple services.
|
||||
uc machine logs uncloud docker corrosion
|
||||
|
||||
# Show last 20 lines per machine (default is 100).
|
||||
uc machine logs -n 20 docker
|
||||
|
||||
# Show all logs without line limit.
|
||||
uc machine logs -n all docker
|
||||
|
||||
# View logs from a specific time range.
|
||||
uc machine logs --since 3h --until 1h30m docker
|
||||
|
||||
# View logs only from specific machines.
|
||||
uc machine logs -m machine1,machine2 uncloud corrosion`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return runLogs(cmd.Context(), uncli, args, options)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().AddFlagSet(logs.Flags(&options))
|
||||
completion.MachinesFlag(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLogs(ctx context.Context, uncli *cli.CLI, services []string, opts logs.Options) error {
|
||||
if len(services) == 0 {
|
||||
services = []string{api.SystemServiceUncloud}
|
||||
}
|
||||
for _, service := range services {
|
||||
if !slices.Contains(api.SystemServices, service) {
|
||||
return fmt.Errorf("invalid system service '%s'; valid services: %s",
|
||||
service, strings.Join(api.SystemServices, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
tail, err := logs.Tail(opts.Tail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
logsOpts := api.ServiceLogsOptions{
|
||||
Follow: opts.Follow,
|
||||
Tail: tail,
|
||||
Since: opts.Since,
|
||||
Until: opts.Until,
|
||||
Machines: cli.ExpandCommaSeparatedValues(opts.Machines),
|
||||
}
|
||||
|
||||
// Resolve machine records for the formatter's column width computation.
|
||||
machines, err := c.ListMachines(ctx, &api.MachineFilter{
|
||||
NamesOrIDs: logsOpts.Machines,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
machineNames := make([]string, 0, len(machines))
|
||||
for _, m := range machines {
|
||||
machineNames = append(machineNames, m.Machine.Name)
|
||||
}
|
||||
|
||||
// Collect one log stream per service. MachineLogs merges across machines internally.
|
||||
serviceStreams := make([]<-chan api.ServiceLogEntry, 0, len(services))
|
||||
for _, service := range services {
|
||||
ch, err := c.MachineLogs(ctx, service, logsOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream logs for system service '%s': %w", service, err)
|
||||
}
|
||||
serviceStreams = append(serviceStreams, ch)
|
||||
}
|
||||
|
||||
var stream <-chan api.ServiceLogEntry
|
||||
if len(serviceStreams) == 1 {
|
||||
stream = serviceStreams[0]
|
||||
} else {
|
||||
// Each MachineLogs stream already runs its own inner merger with stall detection,
|
||||
// so the outer merger across services skips it to avoid duplicate warnings.
|
||||
merger := client.NewLogMerger(serviceStreams, client.LogMergerOptions{})
|
||||
stream = merger.Stream()
|
||||
}
|
||||
|
||||
formatter := logs.NewFormatter(machineNames, services, opts.UTC)
|
||||
|
||||
// Print merged logs.
|
||||
for entry := range stream {
|
||||
formatter.PrintEntry(entry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewListCommand() *cobra.Command {
|
||||
var output string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List machines in a cluster.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return list(cmd.Context(), uncli, output)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "",
|
||||
"Output format: 'json' or empty for a human-readable table.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func list(ctx context.Context, uncli *cli.CLI, output string) error {
|
||||
if output != "" && output != "json" {
|
||||
return fmt.Errorf("unsupported output format '%s' (supported: json)", output)
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
if output == "json" {
|
||||
data, err := json.MarshalIndent(machines.ToNative(), "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal machines: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print the list of machines in a table format.
|
||||
t := tui.NewTable()
|
||||
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS",
|
||||
"OS", "KERNEL", "ARCH", "DOCKER", "VERSION")
|
||||
|
||||
for _, member := range machines {
|
||||
m := member.Machine
|
||||
subnet, _ := m.Network.Subnet.ToPrefix()
|
||||
subnet = netip.PrefixFrom(network.MachineIP(subnet), subnet.Bits())
|
||||
|
||||
publicIP := "-"
|
||||
if m.PublicIp != nil {
|
||||
ip, _ := m.PublicIp.ToAddr()
|
||||
publicIP = ip.String()
|
||||
}
|
||||
|
||||
endpoints := make([]string, len(m.Network.Endpoints))
|
||||
for i, ep := range m.Network.Endpoints {
|
||||
addrPort, _ := ep.ToAddrPort()
|
||||
endpoints[i] = addrPort.String()
|
||||
}
|
||||
|
||||
arch := "-"
|
||||
if m.Arch != "" {
|
||||
arch = m.Arch
|
||||
}
|
||||
|
||||
osName := "-"
|
||||
if m.OsPrettyName != "" {
|
||||
osName = m.OsPrettyName
|
||||
}
|
||||
|
||||
kernel := "-"
|
||||
if m.KernelVersion != "" {
|
||||
kernel = m.KernelVersion
|
||||
}
|
||||
|
||||
daemonVersion := "-"
|
||||
if m.DaemonVersion != "" {
|
||||
daemonVersion = m.DaemonVersion
|
||||
}
|
||||
|
||||
dockerVersion := "-"
|
||||
if m.DockerVersion != "" {
|
||||
dockerVersion = m.DockerVersion
|
||||
}
|
||||
|
||||
t.Row(
|
||||
m.Name,
|
||||
capitalise(member.State.String()),
|
||||
subnet.String(),
|
||||
publicIP,
|
||||
strings.Join(endpoints, tui.Faint.Render(", ")),
|
||||
osName,
|
||||
kernel,
|
||||
arch,
|
||||
dockerVersion,
|
||||
daemonVersion,
|
||||
)
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// capitalise returns a string where the first character is upper case, and the rest is lower case.
|
||||
func capitalise(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRenameCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename OLD_NAME NEW_NAME",
|
||||
Short: "Rename a machine in the cluster.",
|
||||
Long: `Rename a machine in the cluster.
|
||||
|
||||
This command changes the name of an existing machine while preserving all other
|
||||
configuration including network settings, public IP, and cluster membership.`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return rename(cmd.Context(), uncli, args[0], args[1])
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rename(ctx context.Context, uncli *cli.CLI, oldName, newName string) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
machine, err := client.RenameMachine(ctx, oldName, newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename machine: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Machine %q renamed to %q (ID: %s)\n", oldName, machine.Name, machine.Id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/tree"
|
||||
"github.com/docker/compose/v2/pkg/progress"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type removeOptions struct {
|
||||
noReset bool
|
||||
yes bool
|
||||
}
|
||||
|
||||
func NewRmCommand() *cobra.Command {
|
||||
opts := removeOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm MACHINE",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove a machine from a cluster and reset it.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return remove(cmd.Context(), uncli, args[0], opts)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Machines(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
|
||||
"Do not prompt for confirmation before removing the machine.")
|
||||
cmd.Flags().BoolVar(&opts.noReset, "no-reset", false,
|
||||
"Do not reset the machine after removing it from the cluster. This will leave all containers and data intact.")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, uncli *cli.CLI, nameOrID string, opts removeOptions) error {
|
||||
// TODO: automatically choose a connection to the machine that is not being removed.
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Verify the machine exists in the cluster.
|
||||
member, err := client.InspectMachine(ctx, nameOrID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machine '%s': %w", nameOrID, err)
|
||||
}
|
||||
m := member.Machine
|
||||
|
||||
// Create a proxy context for the machine being removed.
|
||||
// This is used for calls that need to run directly on that machine.
|
||||
rmCtx := client.ProxySingleMachineContext(ctx, m.Id)
|
||||
|
||||
// Verify if the machine being removed is the proxy machine we're connected to.
|
||||
proxyMachine, err := client.MachineClient.Inspect(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect proxy machine: %w", err)
|
||||
}
|
||||
if proxyMachine.Id == m.Id {
|
||||
allMachines, err := client.ListMachines(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list machines: %w", err)
|
||||
}
|
||||
if len(allMachines) > 1 {
|
||||
return fmt.Errorf("cannot remove the machine you are currently connected to. "+
|
||||
"Please connect to another machine in the cluster and try again. "+
|
||||
"Change the default connection for the current cluster using 'uc ctx conn' or "+
|
||||
"manually update 'connections' in your Uncloud config (%s). "+
|
||||
"For more information on connecting to a cluster, see "+
|
||||
"https://uncloud.run/docs/concepts/clusters/connecting/", uncli.Config.Path())
|
||||
// It's ok to remove the proxy machine if it's the last one in the cluster.
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: mark the machine as being removed and unschedulable when this is possible to prevent new containers
|
||||
// from being scheduled on it while the removal is in progress.
|
||||
|
||||
reset := !opts.noReset
|
||||
var containers []api.ServiceContainer
|
||||
reachable := false
|
||||
if reset {
|
||||
// Check if the machine is up and has service containers.
|
||||
listOpts := container.ListOptions{All: true}
|
||||
machineContainers, err := client.Docker.ListServiceContainers(rmCtx, "", listOpts)
|
||||
if err == nil {
|
||||
reachable = true
|
||||
containers = machineContainers[0].Containers
|
||||
if len(containers) > 0 {
|
||||
plural := ""
|
||||
if len(containers) > 1 {
|
||||
plural = "s"
|
||||
}
|
||||
fmt.Printf("Found %d service container%s on machine '%s':\n", len(containers), plural, m.Name)
|
||||
lipgloss.Println(formatContainerTree(containers))
|
||||
fmt.Println()
|
||||
fmt.Println("This will remove all service containers from the machine, remove it from the cluster, " +
|
||||
"and reset it to the uninitialised state.")
|
||||
} else {
|
||||
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
|
||||
fmt.Println("This will remove the machine from the cluster and reset it to the uninitialised state.")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("This will remove machine '%s' from the cluster without resetting it as it's unreachable.\n",
|
||||
m.Name)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("This will remove machine '%s' from the cluster without resetting it.\n", m.Name)
|
||||
}
|
||||
|
||||
if !opts.yes {
|
||||
confirmed, err := tui.Confirm("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm removal: %w", err)
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Println("Cancelled. Machine was not removed.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if reset && len(containers) > 0 {
|
||||
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||
return removeContainers(ctx, client, containers)
|
||||
}, uncli.ProgressOut(), "Removing containers")
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove containers: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Initiate reset before removing the machine from the cluster to stop it from updating the cluster store.
|
||||
// This is still optimistic as Reset only triggers the reset process that runs asynchronously.
|
||||
if reset && reachable {
|
||||
_, err = client.MachineClient.Reset(rmCtx, &pb.ResetRequest{})
|
||||
if err != nil {
|
||||
tui.PrintWarning(fmt.Sprintf("Failed to reset machine: %v\n", err))
|
||||
} else {
|
||||
fmt.Println("Machine reset initiated and will complete in the background.")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err = client.RemoveMachine(ctx, &pb.RemoveMachineRequest{Id: m.Id}); err != nil {
|
||||
return fmt.Errorf("remove machine from cluster: %w", err)
|
||||
}
|
||||
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
|
||||
|
||||
// Remove the connection to the machine from the uncloud config if it exists.
|
||||
if uncli.Config != nil {
|
||||
contextName := uncli.ContextOverrideOrCurrent()
|
||||
if context, ok := uncli.Config.Contexts[contextName]; ok {
|
||||
for i, c := range context.Connections {
|
||||
if c.MachineID == m.Id {
|
||||
context.Connections = slices.Delete(context.Connections, i, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := uncli.Config.Save(); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If Caddy was running on this machine and a cluster domain is reserved,
|
||||
// let the user know that the DNS records should be updated.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatContainerTree formats a list of containers grouped by service as a tree structure.
|
||||
func formatContainerTree(containers []api.ServiceContainer) string {
|
||||
if len(containers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Group containers by service.
|
||||
serviceContainers := make(map[string][]api.ServiceContainer)
|
||||
for _, ctr := range containers {
|
||||
serviceName := ctr.ServiceName()
|
||||
serviceContainers[serviceName] = append(serviceContainers[serviceName], ctr)
|
||||
}
|
||||
|
||||
// Build tree output.
|
||||
var output []string
|
||||
serviceNames := slices.Sorted(maps.Keys(serviceContainers))
|
||||
for _, serviceName := range serviceNames {
|
||||
ctrs := serviceContainers[serviceName]
|
||||
mode := ctrs[0].ServiceMode()
|
||||
|
||||
// Format a tree for the service with its containers.
|
||||
plural := ""
|
||||
if len(ctrs) > 1 {
|
||||
plural = "s"
|
||||
}
|
||||
t := tree.Root(fmt.Sprintf("• %s (%s, %d container%s)", serviceName, mode, len(ctrs), plural)).
|
||||
EnumeratorStyle(lipgloss.NewStyle().MarginLeft(2).MarginRight(1))
|
||||
|
||||
// Add containers as children.
|
||||
for _, ctr := range ctrs {
|
||||
state, _ := ctr.HumanState()
|
||||
info := fmt.Sprintf("%s • %s • %s", ctr.Name, tui.FormatImage(ctr.Config.Image, tui.NoStyle), state)
|
||||
t.Child(info)
|
||||
}
|
||||
|
||||
output = append(output, t.String())
|
||||
}
|
||||
|
||||
return strings.Join(output, "\n")
|
||||
}
|
||||
|
||||
// removeContainers removes the given service containers from the machine.
|
||||
func removeContainers(ctx context.Context, client api.Client, containers []api.ServiceContainer) error {
|
||||
if len(containers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
errCh := make(chan error)
|
||||
|
||||
for _, ctr := range containers {
|
||||
wg.Add(1)
|
||||
go func(c api.ServiceContainer) {
|
||||
defer wg.Done()
|
||||
|
||||
// Gracefully stop the container before removing it.
|
||||
err := client.StopContainer(ctx, c.ServiceID(), c.ID, container.StopOptions{})
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
errCh <- fmt.Errorf("stop container '%s': %w", c.ID, err)
|
||||
}
|
||||
|
||||
err = client.RemoveContainer(ctx, c.ServiceID(), c.ID, container.RemoveOptions{
|
||||
// Remove anonymous volumes created by the container.
|
||||
RemoveVolumes: true,
|
||||
})
|
||||
if err != nil && !errors.Is(err, api.ErrNotFound) {
|
||||
errCh <- fmt.Errorf("remove container '%s': %w", c.ID, err)
|
||||
}
|
||||
}(ctr)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
var err error
|
||||
for e := range errCh {
|
||||
err = errors.Join(err, e)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "machine",
|
||||
Aliases: []string{"m"},
|
||||
Short: "Manage machines in the cluster.",
|
||||
}
|
||||
cmd.AddCommand(
|
||||
NewAddCommand(),
|
||||
NewInitCommand(),
|
||||
NewListCommand(),
|
||||
NewLogsCommand(),
|
||||
NewRenameCommand(),
|
||||
NewRmCommand(),
|
||||
NewRTTCommand(),
|
||||
NewUpdateCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
func NewRTTCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rtt",
|
||||
Short: "Show round-trip times between machines.",
|
||||
Long: `Show round-trip times between machines.
|
||||
|
||||
Round-trip time statistics are collected from the Corrosion gossip protocol
|
||||
and represent the median of recent RTT samples between each pair of machines
|
||||
in the cluster. The values shown include the median RTT and standard deviation
|
||||
for each machine-to-machine connection.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return rtt(cmd.Context(), uncli)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func rtt(ctx context.Context, uncli *cli.CLI) error {
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to cluster: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Setup context to proxy request to all machines.
|
||||
ctx = client.ProxyMachinesContext(ctx, nil)
|
||||
|
||||
resp, err := client.MachineClient.InspectMachine(ctx, &emptypb.Empty{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect machines: %w", err)
|
||||
}
|
||||
|
||||
// Map machine IDs to names for display from the response.
|
||||
machineNames := make(map[string]string)
|
||||
for _, m := range resp.Machines {
|
||||
// NOTE: Metadata should never be nil in practice. This is legacy fallback that will be removed.
|
||||
if m.Metadata == nil {
|
||||
tui.PrintWarning("metadata is missing in response from unknown server")
|
||||
continue
|
||||
}
|
||||
if m.Metadata.Error != "" {
|
||||
tui.PrintWarning(fmt.Sprintf("failed to inspect machine '%s': %s", m.Metadata.MachineName, m.Metadata.Error))
|
||||
continue
|
||||
}
|
||||
if m.Machine == nil {
|
||||
continue
|
||||
}
|
||||
machineNames[m.Machine.Id] = m.Machine.Name
|
||||
}
|
||||
|
||||
type row struct {
|
||||
machine string
|
||||
peer string
|
||||
median time.Duration
|
||||
stdDev time.Duration
|
||||
}
|
||||
var rows []row
|
||||
|
||||
for _, m := range resp.Machines {
|
||||
// Unlikely to occur, but might be a possible edge case when
|
||||
// a machine is still initializing. So just to be safe.
|
||||
if m.Machine == nil || m.Rtts == nil {
|
||||
continue
|
||||
}
|
||||
for peerID, stats := range m.Rtts {
|
||||
peerName := peerID
|
||||
if name, ok := machineNames[peerID]; ok {
|
||||
peerName = name
|
||||
}
|
||||
rows = append(rows, row{
|
||||
machine: m.Machine.Name,
|
||||
peer: peerName,
|
||||
median: stats.Median.AsDuration(),
|
||||
stdDev: stats.StdDev.AsDuration(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by machine name then peer name.
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].machine == rows[j].machine {
|
||||
return rows[i].peer < rows[j].peer
|
||||
}
|
||||
return rows[i].machine < rows[j].machine
|
||||
})
|
||||
|
||||
// Print table.
|
||||
t := tui.NewTable()
|
||||
t.Headers("MACHINE", "PEER", "MEDIAN", "STDDEV")
|
||||
|
||||
for _, r := range rows {
|
||||
t.Row(r.machine, r.peer, tui.FormatRTT(r.median), formatRTTStdDev(r.stdDev))
|
||||
}
|
||||
|
||||
lipgloss.Println(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatRTTStdDev formats a round-trip time standard deviation with one decimal place, e.g. "±19.4ms".
|
||||
func formatRTTStdDev(d time.Duration) string {
|
||||
return fmt.Sprintf("±%.1fms", float64(d)/float64(time.Millisecond))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package machine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"github.com/psviderski/uncloud/internal/cli"
|
||||
"github.com/psviderski/uncloud/internal/cli/completion"
|
||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||
"github.com/psviderski/uncloud/internal/machine/network"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type updateOptions struct {
|
||||
name string
|
||||
publicIP string
|
||||
wgEndpoints []string
|
||||
}
|
||||
|
||||
func NewUpdateCommand() *cobra.Command {
|
||||
opts := updateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update MACHINE [flags]",
|
||||
Short: "Update machine configuration in the cluster.",
|
||||
Long: `Update machine configuration in the cluster.
|
||||
|
||||
Change the name, public IP address, or WireGuard endpoints of an existing machine.
|
||||
At least one flag must be specified to perform an update.`,
|
||||
Example: ` # Rename a machine.
|
||||
uc machine update machine1 --name web-server
|
||||
|
||||
# Set the public IP address of a machine.
|
||||
uc machine update machine1 --public-ip 203.0.113.10
|
||||
|
||||
# Remove the public IP address from a machine.
|
||||
uc machine update machine1 --public-ip none
|
||||
|
||||
# Update WireGuard endpoints for a machine.
|
||||
uc machine update machine1 --wg-endpoint 203.0.113.10 --wg-endpoint 192.168.1.5
|
||||
|
||||
# Update multiple properties at once.
|
||||
uc machine update machine1 --name web-server --public-ip 203.0.113.10`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return update(cmd.Context(), uncli, cmd, opts, args[0])
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||
return completion.Machines(cmd.Context(), uncli, args, toComplete)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(
|
||||
&opts.name, "name", "",
|
||||
"New name for the machine",
|
||||
)
|
||||
cmd.Flags().StringVar(
|
||||
&opts.publicIP, "public-ip", "",
|
||||
fmt.Sprintf("Public IP address of the machine for ingress configuration. Use '%s' or '' to remove the public IP.",
|
||||
PublicIPNone),
|
||||
)
|
||||
cmd.Flags().StringSliceVar(
|
||||
&opts.wgEndpoints, "wg-endpoint", nil,
|
||||
fmt.Sprintf("WireGuard endpoint address that other machines in the cluster should use to establish "+
|
||||
"WireGuard connections\n"+
|
||||
"to this machine. This doesn't change the address/port WireGuard listens on the machine.\n"+
|
||||
"Format: IP, IP:PORT, IPv6, or [IPv6]:PORT. Default port is %d if omitted.\n",
|
||||
network.DefaultWireGuardPort)+
|
||||
"Multiple endpoints can be specified by repeating the flag or using a comma-separated list.",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func update(ctx context.Context, uncli *cli.CLI, cmd *cobra.Command, opts updateOptions, machineNameOrID string) error {
|
||||
// Check if at least one flag was explicitly set.
|
||||
if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("public-ip") && !cmd.Flags().Changed("wg-endpoint") {
|
||||
return fmt.Errorf("at least one update flag must be specified (--name, --public-ip, --wg-endpoint)")
|
||||
}
|
||||
|
||||
client, err := uncli.ConnectCluster(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Resolve the machine to capture its current configuration for the before/after report and to validate existence.
|
||||
machine, err := client.InspectMachine(ctx, machineNameOrID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find machine: %w", err)
|
||||
}
|
||||
|
||||
req := &pb.UpdateMachineRequest{}
|
||||
|
||||
if opts.name != "" {
|
||||
req.Name = &opts.name
|
||||
}
|
||||
|
||||
// Check if --public-ip flag was explicitly provided
|
||||
if cmd.Flags().Changed("public-ip") {
|
||||
if opts.publicIP == "" || opts.publicIP == PublicIPNone {
|
||||
req.PublicIp = &pb.IP{} // Empty IP to signal removal
|
||||
} else {
|
||||
// Parse and validate the public IP
|
||||
ip, err := netip.ParseAddr(opts.publicIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid public IP address %q: %w", opts.publicIP, err)
|
||||
}
|
||||
req.PublicIp = pb.NewIP(ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and set endpoints if the flag was explicitly provided.
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
expanded := cli.ExpandCommaSeparatedValues(opts.wgEndpoints)
|
||||
endpoints, err := cli.ParseWireGuardEndpoints(expanded, network.DefaultWireGuardPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
return fmt.Errorf("at least one endpoint must be specified if --wg-endpoint flag is used")
|
||||
}
|
||||
req.Endpoints = endpoints
|
||||
}
|
||||
|
||||
updatedMachine, err := client.UpdateMachine(ctx, machine.Machine.Id, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update machine: %w", err)
|
||||
}
|
||||
|
||||
// Report what was changed
|
||||
changes := make([]string, 0)
|
||||
if opts.name != "" {
|
||||
changes = append(changes, fmt.Sprintf("name: %q -> %q", machine.Machine.Name, updatedMachine.Name))
|
||||
}
|
||||
if cmd.Flags().Changed("public-ip") {
|
||||
oldIP := PublicIPNone
|
||||
if machine.Machine.PublicIp != nil {
|
||||
if addr, err := machine.Machine.PublicIp.ToAddr(); err == nil {
|
||||
oldIP = addr.String()
|
||||
}
|
||||
}
|
||||
newIP := PublicIPNone
|
||||
if updatedMachine.PublicIp != nil {
|
||||
if addr, err := updatedMachine.PublicIp.ToAddr(); err == nil {
|
||||
newIP = addr.String()
|
||||
}
|
||||
}
|
||||
changes = append(changes, fmt.Sprintf("public IP: %s -> %s", oldIP, newIP))
|
||||
}
|
||||
if cmd.Flags().Changed("wg-endpoint") {
|
||||
formatEndpoints := func(eps []*pb.IPPort) string {
|
||||
if len(eps) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, len(eps))
|
||||
for i, ep := range eps {
|
||||
ap, _ := ep.ToAddrPort()
|
||||
parts[i] = ap.String()
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
oldEndpoints := formatEndpoints(machine.Machine.Network.Endpoints)
|
||||
newEndpoints := formatEndpoints(updatedMachine.Network.Endpoints)
|
||||
changes = append(changes, fmt.Sprintf("endpoints: %s -> %s", oldEndpoints, newEndpoints))
|
||||
}
|
||||
|
||||
fmt.Printf("Machine '%s' (ID: %s) configuration updated:\n", updatedMachine.Name, updatedMachine.Id)
|
||||
for _, change := range changes {
|
||||
fmt.Printf(" %s\n", change)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user