Files
wild-cli/cmd/node.go

579 lines
16 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
)
// Node commands
var nodeCmd = &cobra.Command{
Use: "node",
Short: "Manage nodes",
}
var nodeDiscoverCmd = &cobra.Command{
Use: "discover [subnet]",
Short: "Discover nodes on network",
Long: `Discover nodes on the network by scanning a subnet or auto-detecting local networks.
If a subnet is provided (e.g., 192.168.1.0/24), only that subnet will be scanned.
If no subnet is provided, all local networks will be automatically detected and scanned.
Examples:
wild node discover # Auto-detect local networks
wild node discover 192.168.1.0/24 # Scan specific subnet`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
// Check if --cancel flag is set
shouldCancel, _ := cmd.Flags().GetBool("cancel")
if shouldCancel {
// Cancel any running discovery first
_, err := apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/discovery/cancel", inst), nil)
if err != nil {
// Ignore error if no discovery is running
fmt.Println("No active discovery to cancel, starting new discovery...")
} else {
fmt.Println("Cancelled previous discovery, starting new discovery...")
}
}
// Build request body
body := map[string]interface{}{}
if len(args) > 0 {
body["subnet"] = args[0]
fmt.Printf("Starting discovery for subnet %s...\n", args[0])
} else {
fmt.Println("Starting discovery (auto-detecting local networks)...")
}
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/nodes/discover", inst), body)
if err != nil {
return err
}
// Set up signal handling for Ctrl-C
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Handle signals in a goroutine
go func() {
<-sigChan
fmt.Println("\n\nCancelling discovery...")
cancel()
// Call cancel API
_, err := apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/discovery/cancel", inst), nil)
if err != nil {
fmt.Printf("Failed to cancel discovery: %v\n", err)
} else {
fmt.Println("Discovery cancelled successfully")
}
os.Exit(0)
}()
// Poll for completion
fmt.Println("Scanning nodes... (Press Ctrl-C to cancel)")
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/discovery", inst))
if err != nil {
return err
}
active, _ := resp.Data["active"].(bool)
if !active {
// Discovery complete
nodesFound := resp.GetArray("nodes_found")
if len(nodesFound) == 0 {
fmt.Println("\nNo Talos nodes found")
return nil
}
fmt.Printf("\nFound %d node(s) in maintenance mode:\n\n", len(nodesFound))
fmt.Printf("%-15s %-15s %-15s\n", "IP", "VERSION", "HOSTNAME")
fmt.Println("-----------------------------------------------------")
for _, node := range nodesFound {
if m, ok := node.(map[string]interface{}); ok {
version := m["version"]
if version == nil {
version = ""
}
hostname := m["hostname"]
if hostname == nil {
hostname = ""
}
fmt.Printf("%-15s %-15s %-15s\n",
m["ip"], version, hostname)
}
}
return nil
}
// Still running, show progress
fmt.Print(".")
}
}
},
}
var nodeDetectCmd = &cobra.Command{
Use: "detect <ip>",
Short: "Detect hardware on a single node",
Long: `Detect hardware configuration on a single node in maintenance mode.
This queries the node for available network interfaces and disks, helping you
decide which hardware to use when adding the node to the cluster.
Example:
wild node detect 192.168.1.31
Output shows:
- Available network interfaces
- Available disks with sizes
- Recommended selections`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
nodeIP := args[0]
// Call API to detect hardware
resp, err := apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/nodes/detect", inst), map[string]string{
"ip": nodeIP,
})
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Text format - show hardware details
fmt.Printf("Hardware detected for node at %s:\n\n", nodeIP)
if iface := resp.GetString("interface"); iface != "" {
fmt.Printf("Interface: %s\n", iface)
}
if disks := resp.GetArray("disks"); len(disks) > 0 {
fmt.Printf("\nAvailable Disks:\n")
for _, diskData := range disks {
diskMap, ok := diskData.(map[string]interface{})
if !ok {
continue
}
path, _ := diskMap["path"].(string)
size, _ := diskMap["size"].(float64) // JSON numbers are float64
// Format size in GB/TB
sizeGB := size / (1024 * 1024 * 1024)
var sizeStr string
if sizeGB >= 1000 {
sizeStr = fmt.Sprintf("%.1f TB", sizeGB/1024)
} else {
sizeStr = fmt.Sprintf("%.1f GB", sizeGB)
}
fmt.Printf(" - %s (%s)\n", path, sizeStr)
}
}
if selected := resp.GetString("selected_disk"); selected != "" {
fmt.Printf("\nRecommended Disk: %s\n", selected)
}
fmt.Printf("\nTo add this node:\n")
fmt.Printf(" wild node add <hostname> <role> --current-ip %s --target-ip <target-ip> --disk <disk> --interface <interface>\n", nodeIP)
return nil
},
}
var nodeListCmd = &cobra.Command{
Use: "list",
Short: "List configured nodes",
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/nodes", inst))
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
nodes := resp.GetArray("nodes")
if len(nodes) == 0 {
fmt.Println("No nodes found")
return nil
}
fmt.Printf("%-20s %-12s %-15s\n", "HOSTNAME", "ROLE", "TARGET IP")
fmt.Println("-----------------------------------------------------")
for _, node := range nodes {
if m, ok := node.(map[string]interface{}); ok {
fmt.Printf("%-20s %-12s %-15s\n",
m["hostname"], m["role"], m["target_ip"])
}
}
return nil
},
}
var nodeShowCmd = &cobra.Command{
Use: "show <hostname>",
Short: "Show node details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/nodes/%s", inst, args[0]))
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Text format - show node details
fmt.Printf("Hostname: %s\n", resp.GetString("hostname"))
fmt.Printf("Role: %s\n", resp.GetString("role"))
fmt.Printf("Target IP: %s\n", resp.GetString("target_ip"))
fmt.Printf("Disk: %s\n", resp.GetString("disk"))
fmt.Printf("Interface: %s\n", resp.GetString("interface"))
fmt.Printf("Version: %s\n", resp.GetString("version"))
fmt.Printf("Schematic ID: %s\n", resp.GetString("schematic_id"))
fmt.Printf("Configured: %v\n", resp.Data["configured"])
fmt.Printf("Deployed: %v\n", resp.Data["deployed"])
return nil
},
}
var nodeAddCmd = &cobra.Command{
Use: "add <hostname> <role>",
Short: "Add a node to cluster configuration",
Long: `Add a node to the cluster configuration with required hardware details.
Role must be either 'controlplane' or 'worker'.
The node configuration will be stored in the instance config and used during apply.
Examples:
# Node in maintenance mode (PXE booted)
wild node add control-1 controlplane --current-ip 192.168.1.100 --target-ip 192.168.1.31 --disk /dev/sda
# Node already applied (unusual, only if config was removed manually)
wild node add worker-1 worker --target-ip 192.168.1.32 --disk /dev/nvme0n1`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
// Get flags
targetIP, _ := cmd.Flags().GetString("target-ip")
currentIP, _ := cmd.Flags().GetString("current-ip")
disk, _ := cmd.Flags().GetString("disk")
iface, _ := cmd.Flags().GetString("interface")
schematicID, _ := cmd.Flags().GetString("schematic-id")
maintenance, _ := cmd.Flags().GetBool("maintenance")
// Build request body
body := map[string]interface{}{
"hostname": args[0],
"role": args[1],
}
if targetIP != "" {
body["target_ip"] = targetIP
}
if currentIP != "" {
body["current_ip"] = currentIP
}
if disk != "" {
body["disk"] = disk
}
if iface != "" {
body["interface"] = iface
}
if schematicID != "" {
body["schematic_id"] = schematicID
}
if maintenance {
body["maintenance"] = true
}
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/nodes", inst), body)
if err != nil {
return err
}
fmt.Printf("Node added: %s (%s)\n", args[0], args[1])
if targetIP != "" {
fmt.Printf(" Target IP: %s\n", targetIP)
}
if disk != "" {
fmt.Printf(" Disk: %s\n", disk)
}
return nil
},
}
var nodeApplyCmd = &cobra.Command{
Use: "apply <hostname>",
Short: "Apply Talos configuration to node",
Long: `Generate and apply Talos configuration to a node.
This command:
1. Auto-fetches patch templates if missing
2. Generates node-specific configuration from templates
3. Merges base config with node patch
4. Applies configuration to node (using --insecure if in maintenance mode)
5. Updates node state after successful application
Examples:
# Apply to node in maintenance mode (PXE booted)
wild node apply control-1
# Re-apply to production node (updates configuration)
wild node apply worker-1`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/nodes/%s/apply", inst, args[0]), nil)
if err != nil {
return err
}
fmt.Printf("Node configuration applied: %s\n", args[0])
if msg := resp.GetString("message"); msg != "" {
fmt.Printf("%s\n", msg)
}
return nil
},
}
var nodeUpdateCmd = &cobra.Command{
Use: "update <hostname>",
Short: "Update node configuration",
Long: `Update existing node configuration with partial updates.
This command modifies node properties without requiring all fields.
Examples:
# Update disk after hardware change
wild node update worker-1 --disk /dev/sdb
# Move node to maintenance mode
wild node update control-1 --current-ip 192.168.1.100 --maintenance
# Clear maintenance after successful apply
wild node update control-1 --no-maintenance`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
// Get flags
targetIP, _ := cmd.Flags().GetString("target-ip")
currentIP, _ := cmd.Flags().GetString("current-ip")
disk, _ := cmd.Flags().GetString("disk")
iface, _ := cmd.Flags().GetString("interface")
schematicID, _ := cmd.Flags().GetString("schematic-id")
maintenance, _ := cmd.Flags().GetBool("maintenance")
noMaintenance, _ := cmd.Flags().GetBool("no-maintenance")
// Build request body with only provided fields
body := map[string]interface{}{}
if targetIP != "" {
body["target_ip"] = targetIP
}
if currentIP != "" {
body["current_ip"] = currentIP
}
if disk != "" {
body["disk"] = disk
}
if iface != "" {
body["interface"] = iface
}
if schematicID != "" {
body["schematic_id"] = schematicID
}
if maintenance {
body["maintenance"] = true
}
if noMaintenance {
body["maintenance"] = false
}
if len(body) == 0 {
return fmt.Errorf("no updates specified")
}
_, err = apiClient.Put(fmt.Sprintf("/api/v1/instances/%s/nodes/%s", inst, args[0]), body)
if err != nil {
return err
}
fmt.Printf("Node updated: %s\n", args[0])
return nil
},
}
var nodeFetchTemplatesCmd = &cobra.Command{
Use: "fetch-patch-templates",
Short: "Fetch patch templates from directory",
Long: `Copy latest patch templates from directory/setup/cluster-nodes/patch.templates to instance.
This command is automatically called by 'apply' if templates are missing.
You can use it manually to update templates.`,
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/nodes/fetch-templates", inst), nil)
if err != nil {
return err
}
fmt.Println("Templates fetched successfully")
return nil
},
}
var nodeCancelDiscoveryCmd = &cobra.Command{
Use: "cancel-discovery",
Short: "Cancel active node discovery",
Long: `Cancel an active node discovery operation.
Use this if discovery gets stuck or you want to stop a running scan.
Example:
wild node cancel-discovery`,
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/discovery/cancel", inst), nil)
if err != nil {
return err
}
fmt.Println("Discovery cancelled successfully")
return nil
},
}
var nodeDeleteCmd = &cobra.Command{
Use: "delete <hostname>",
Short: "Delete a node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
_, err = apiClient.Delete(fmt.Sprintf("/api/v1/instances/%s/nodes/%s", inst, args[0]))
if err != nil {
return err
}
fmt.Printf("Node deleted: %s\n", args[0])
return nil
},
}
func init() {
nodeCmd.AddCommand(nodeDiscoverCmd)
nodeCmd.AddCommand(nodeCancelDiscoveryCmd)
nodeCmd.AddCommand(nodeDetectCmd)
nodeCmd.AddCommand(nodeListCmd)
nodeCmd.AddCommand(nodeShowCmd)
nodeCmd.AddCommand(nodeAddCmd)
nodeCmd.AddCommand(nodeApplyCmd)
nodeCmd.AddCommand(nodeUpdateCmd)
nodeCmd.AddCommand(nodeFetchTemplatesCmd)
nodeCmd.AddCommand(nodeDeleteCmd)
// Add flags to node discover command
nodeDiscoverCmd.Flags().Bool("cancel", false, "Cancel any running discovery before starting")
// Add flags to node add command
nodeAddCmd.Flags().String("target-ip", "", "Target IP address for production")
nodeAddCmd.Flags().String("current-ip", "", "Current IP address (for maintenance mode)")
nodeAddCmd.Flags().String("disk", "", "Disk device (required, e.g., /dev/sda)")
nodeAddCmd.Flags().String("interface", "", "Network interface (optional, e.g., eth0)")
nodeAddCmd.Flags().String("schematic-id", "", "Talos schematic ID (optional, uses instance default)")
nodeAddCmd.Flags().Bool("maintenance", false, "Mark node as in maintenance mode")
// Add flags to node update command
nodeUpdateCmd.Flags().String("target-ip", "", "Update target IP address")
nodeUpdateCmd.Flags().String("current-ip", "", "Update current IP address")
nodeUpdateCmd.Flags().String("disk", "", "Update disk device")
nodeUpdateCmd.Flags().String("interface", "", "Update network interface")
nodeUpdateCmd.Flags().String("schematic-id", "", "Update Talos schematic ID")
nodeUpdateCmd.Flags().Bool("maintenance", false, "Set maintenance mode")
nodeUpdateCmd.Flags().Bool("no-maintenance", false, "Clear maintenance mode")
}