Files
wild-cli/cmd/node.go
2025-10-11 17:19:11 +00:00

483 lines
13 KiB
Go

package cmd
import (
"fmt"
"time"
"github.com/spf13/cobra"
)
// Node commands
var nodeCmd = &cobra.Command{
Use: "node",
Short: "Manage nodes",
}
var nodeDiscoverCmd = &cobra.Command{
Use: "discover <ip>...",
Short: "Discover nodes on network",
Long: "Discover nodes on the network by scanning the provided IP addresses or ranges",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
fmt.Printf("Starting discovery for %d IP(s)...\n", len(args))
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/nodes/discover", inst), map[string]interface{}{
"ip_list": args,
})
if err != nil {
return err
}
// Poll for completion
fmt.Println("Scanning nodes...")
for {
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):\n\n", len(nodesFound))
fmt.Printf("%-15s %-12s %-10s\n", "IP", "INTERFACE", "VERSION")
fmt.Println("-----------------------------------------------")
for _, node := range nodesFound {
if m, ok := node.(map[string]interface{}); ok {
fmt.Printf("%-15s %-12s %-10s\n",
m["ip"], m["interface"], m["version"])
}
}
return nil
}
// Still running, wait a bit
fmt.Print(".")
time.Sleep(500 * time.Millisecond)
}
},
}
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 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(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 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")
}