diff --git a/internal/cli/acl.go b/internal/cli/acl.go index deba309..2f8cea8 100644 --- a/internal/cli/acl.go +++ b/internal/cli/acl.go @@ -32,6 +32,7 @@ var ( aclRevokeNamespace string aclCheckNamespace string aclCheckPermission string + aclCheckVerbose bool ) var aclCmd = &cobra.Command{ @@ -351,6 +352,16 @@ read, write, admin (default: read).`, } identity.Namespace = ns allowed := a.Check(identity, ns, perm) + if aclCheckVerbose { + fmt.Fprintf(cmd.ErrOrStderr(), "ACL path: %s\n", paths.ACLPath()) + fmt.Fprintf(cmd.ErrOrStderr(), "Identity: kind=%s id=%s ns=%s\n", identity.Kind, identity.ID, ns) + fmt.Fprintf(cmd.ErrOrStderr(), "Permission: %s -> allowed=%v\n", permStr, allowed) + entries := a.List() + fmt.Fprintf(cmd.ErrOrStderr(), "ACL entries (%d):\n", len(entries)) + for _, e := range entries { + fmt.Fprintf(cmd.ErrOrStderr(), " kind=%s id=%s ns=%s perms=%d\n", e.Identity.Kind, e.Identity.ID, e.Namespace, e.Permissions) + } + } if jsonOutput { return printJSON(map[string]any{ "identity": identity, @@ -373,6 +384,7 @@ func init() { aclGrantCmd.Flags().StringVar(&aclGrantPermissions, "permissions", "read", "comma-separated permissions: read,write,admin") aclRevokeCmd.Flags().StringVar(&aclRevokeNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)") aclCheckCmd.Flags().StringVar(&aclCheckNamespace, "namespace", "", "namespace scope (required for tokens; defaults to spiffe path ns)") + aclCheckCmd.Flags().BoolVar(&aclCheckVerbose, "verbose", false, "print ACL path + loaded entries for debugging") aclCheckCmd.Flags().StringVar(&aclCheckPermission, "permission", "read", "permission to check: read, write, or admin") aclCmd.AddCommand(aclGrantCmd) diff --git a/internal/cli/node_capacity.go b/internal/cli/node_capacity.go index cfb4c9e..42d5efe 100644 --- a/internal/cli/node_capacity.go +++ b/internal/cli/node_capacity.go @@ -11,10 +11,14 @@ 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" ) @@ -72,8 +76,11 @@ var nodeCapacitySetCmd = &cobra.Command{ 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 { - if capSetCPU <= 0 || capSetMem <= 0 || capSetDisk <= 0 { - return fmt.Errorf("--cpu, --memory, and --disk must all be positive") + // 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 == "" { @@ -87,11 +94,27 @@ var nodeCapacitySetCmd = &cobra.Command{ } 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: capSetCPU, - MemoryMiB: capSetMem, - DiskMiB: capSetDisk, + CPUMillicores: cpu, + MemoryMiB: mem, + DiskMiB: disk, } if err := repo.Upsert(ctx, c); err != nil { return err @@ -137,6 +160,92 @@ var nodeCapacityListCmd = &cobra.Command{ }, } + +// 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") @@ -144,6 +253,7 @@ func init() { nodeCapacitySetCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')") nodeCapacityShowCmd.Flags().StringVar(&capNodeID, "node", "", "node id (defaults to 'self')") - nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd) + nodeCapacityAutoCmd.Flags().StringVar(&capNodeID, "node", "", "node name or ID to auto-discover capacity for") + nodeCapacityCmd.AddCommand(nodeCapacityShowCmd, nodeCapacitySetCmd, nodeCapacityListCmd, nodeCapacityAutoCmd) nodeCmd.AddCommand(nodeCapacityCmd) } diff --git a/internal/cli/node_capacity_test.go b/internal/cli/node_capacity_test.go index ff29262..971b28c 100644 --- a/internal/cli/node_capacity_test.go +++ b/internal/cli/node_capacity_test.go @@ -10,16 +10,18 @@ import ( "git.cloudinit.dev/coreci/orca/internal/store" ) -func TestNodeCapacitySetMissingArgs(t *testing.T) { +func TestNodeCapacitySetPartialUpdate(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() resetRootFlags(t) var buf bytes.Buffer rootCmd.SetOut(&buf) rootCmd.SetErr(&buf) + // REQ-168: partial updates are now allowed. Setting only --cpu + // should succeed (memory/disk default to 0 or existing values). rootCmd.SetArgs([]string{"node", "capacity", "set", "--cpu", "1000"}) - if err := rootCmd.Execute(); err == nil { - t.Fatal("expected error for capacity set missing memory/disk, got nil") + if err := rootCmd.Execute(); err != nil { + t.Fatalf("expected success for partial capacity set, got: %v", err) } }