// node_capacity.go implements `orca node capacity` for v0.2 P02. // The capacity declaration is per-node (cpu_millicores, memory_mib, // disk_mib) and feeds the bin-packing scheduler. // // REQ-028: HCL/YAML schema for NodeCapacity — the CLI accepts the // three numeric flags and writes a row to the `node_capacity` table. // A future enhancement can read `~/.orca/node.hcl` at join time // (out of scope for P02). package cli import ( "context" "fmt" "strconv" "strings" "time" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/sshpush" "git.cloudinit.dev/coreci/orca/internal/store" ) var ( capSetCPU int64 capSetMem int64 capSetDisk int64 capNodeID string ) var nodeCapacityCmd = &cobra.Command{ Use: "capacity", Short: "Manage node capacity declarations (P02 bin-packing input)", Long: "Read or write the per-node capacity used by the multi-node scheduler.", } var nodeCapacityShowCmd = &cobra.Command{ Use: "show [node-id]", Short: "Show capacity for a node (defaults to 'self')", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := capNodeID if id == "" && len(args) > 0 { id = args[0] } if id == "" { id = "self" } ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) defer cancel() db, closer, err := openDB() if err != nil { return err } defer closer() repo := store.NewCapacityRepo(db) c, err := repo.Get(ctx, id) if err != nil { return fmt.Errorf("node %s: %w (use `orca node capacity --set` to declare)", id, err) } if jsonOutput { return printJSON(c) } fmt.Fprintf(cmd.OutOrStdout(), "Node: %s\n", c.NodeID) fmt.Fprintf(cmd.OutOrStdout(), "CPU: %d millicores\n", c.CPUMillicores) fmt.Fprintf(cmd.OutOrStdout(), "Memory: %d MiB\n", c.MemoryMiB) fmt.Fprintf(cmd.OutOrStdout(), "Disk: %d MiB\n", c.DiskMiB) fmt.Fprintf(cmd.OutOrStdout(), "Updated: %s\n", c.UpdatedAt.UTC().Format(time.RFC3339)) return nil }, } var nodeCapacitySetCmd = &cobra.Command{ Use: "set", Short: "Declare capacity for a node (used by bin-packing)", Long: "Write cpu_millicores, memory_mib, and disk_mib for the named node. Idempotent: subsequent calls overwrite.", RunE: func(cmd *cobra.Command, args []string) error { // REQ-168: allow partial updates. At least one dimension // must be positive; the others are read from the existing // row (or default to 0 if no row exists yet). if capSetCPU <= 0 && capSetMem <= 0 && capSetDisk <= 0 { return fmt.Errorf("at least one of --cpu, --memory, or --disk must be positive") } id := capNodeID if id == "" { id = "self" } ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) defer cancel() db, closer, err := openDB() if err != nil { return err } defer closer() repo := store.NewCapacityRepo(db) // Read existing row for partial update. existing, _ := repo.Get(ctx, id) cpu := capSetCPU mem := capSetMem disk := capSetDisk if existing != nil { if cpu <= 0 { cpu = existing.CPUMillicores } if mem <= 0 { mem = existing.MemoryMiB } if disk <= 0 { disk = existing.DiskMiB } } c := &store.NodeCapacity{ NodeID: id, CPUMillicores: cpu, MemoryMiB: mem, DiskMiB: disk, } if err := repo.Upsert(ctx, c); err != nil { return err } if jsonOutput { return printJSON(c) } fmt.Fprintf(cmd.OutOrStdout(), "✓ Capacity set for %s: cpu=%d mem=%d disk=%d\n", c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB) return nil }, } var nodeCapacityListCmd = &cobra.Command{ Use: "list", Short: "List all node capacity declarations", RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) defer cancel() db, closer, err := openDB() if err != nil { return err } defer closer() repo := store.NewCapacityRepo(db) rows, err := repo.List(ctx) if err != nil { return err } if jsonOutput { return printJSON(rows) } if len(rows) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No capacity declarations. Use `orca node capacity --set` to add one.") return nil } fmt.Fprintf(cmd.OutOrStdout(), "%-20s %12s %12s %12s %s\n", "NODE", "CPU(mc)", "MEM(MiB)", "DISK(MiB)", "UPDATED") for _, c := range rows { fmt.Fprintf(cmd.OutOrStdout(), "%-20s %12d %12d %12d %s\n", c.NodeID, c.CPUMillicores, c.MemoryMiB, c.DiskMiB, c.UpdatedAt.UTC().Format(time.RFC3339)) } return nil }, } // nodeCapacityAutoCmd discovers capacity by SSHing to the node and // reading nproc, /proc/meminfo, df (REQ-168, Phase D2). var capAutoPct int var nodeCapacityAutoCmd = &cobra.Command{ Use: "auto [percentage]", Short: "Auto-discover node capacity via SSH (default 75% of physical)", Long: `SSH to the specified node and discover CPU cores, memory, and disk capacity. Multiplies the physical values by the given percentage (default 75) to reserve headroom for the OS. The discovered values are written to the capacity table (same as 'orca node capacity set').`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { pct := 75 if len(args) > 0 { var err error pct, err = strconv.Atoi(args[0]) if err != nil || pct < 1 || pct > 100 { return fmt.Errorf("percentage must be 1-100, got %q", args[0]) } } id := capNodeID if id == "" { return fmt.Errorf("--node is required for capacity auto") } // Build SSH transport and exec discovery commands. transport := sshpush.NewTransport(certpaths.SSHKeyPath(), certpaths.KnownHostsPath()) defer transport.Close() ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) defer cancel() // CPU: nproc cpuOut, err := transport.Exec(ctx, id, "nproc") if err != nil { return fmt.Errorf("capacity auto: SSH exec nproc on %s: %w", id, err) } cores, err := strconv.Atoi(strings.TrimSpace(string(cpuOut))) if err != nil { return fmt.Errorf("capacity auto: parse nproc output %q: %w", string(cpuOut), err) } // Memory: MemTotal from /proc/meminfo (in kB -> MiB) memOut, err := transport.Exec(ctx, id, "awk '/MemTotal/{print $2}' /proc/meminfo") if err != nil { return fmt.Errorf("capacity auto: SSH exec meminfo on %s: %w", id, err) } memKB, err := strconv.ParseInt(strings.TrimSpace(string(memOut)), 10, 64) if err != nil { return fmt.Errorf("capacity auto: parse meminfo output %q: %w", string(memOut), err) } // Disk: df on root (1K-blocks -> MiB) diskOut, err := transport.Exec(ctx, id, "df --output=size / | tail -1") if err != nil { return fmt.Errorf("capacity auto: SSH exec df on %s: %w", id, err) } diskKB, err := strconv.ParseInt(strings.TrimSpace(string(diskOut)), 10, 64) if err != nil { return fmt.Errorf("capacity auto: parse df output %q: %w", string(diskOut), err) } // Apply percentage, convert to millicores/MiB. cpuM := int64(cores) * 1000 * int64(pct) / 100 memMib := memKB * int64(pct) / 100 / 1024 diskMib := diskKB * int64(pct) / 100 / 1024 // Write to DB. db, closer, err := openDB() if err != nil { return err } defer closer() repo := store.NewCapacityRepo(db) c := &store.NodeCapacity{ NodeID: id, CPUMillicores: cpuM, MemoryMiB: memMib, DiskMiB: diskMib, } if err := repo.Upsert(ctx, c); err != nil { return err } if jsonOutput { return printJSON(c) } fmt.Fprintf(cmd.OutOrStdout(), "Capacity auto-discovered for %s (%d%%): cpu=%dm, mem=%dMiB, disk=%dMiB\n", id, pct, cpuM, memMib, diskMib) return nil }, } func init() { nodeCapacitySetCmd.Flags().Int64Var(&capSetCPU, "cpu", 0, "CPU capacity in millicores (1000 = 1 vCPU)") nodeCapacitySetCmd.Flags().Int64Var(&capSetMem, "memory", 0, "Memory capacity in MiB") nodeCapacitySetCmd.Flags().Int64Var(&capSetDisk, "disk", 0, "Disk capacity in MiB") nodeCapacitySetCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')") nodeCapacityShowCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')") nodeCapacityAutoCmd.Flags().StringVar(&capNodeID, "node", "", "node name or ID to auto-discover capacity for") nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd, nodeCapacityAutoCmd) nodeCmd.AddCommand(nodeCapacityCmd) }