diff --git a/README.md b/README.md index 0500db2..0b4ebfa 100644 --- a/README.md +++ b/README.md @@ -149,11 +149,42 @@ capfox stop # Stop the server capfox status # Show system status capfox stats # Show task statistics capfox ask # Check task capacity +capfox run # Run command if capacity available +capfox notify # Notify server about task start capfox reload # Reload configuration capfox tui # Open TUI dashboard capfox config # Show current config ``` +### Run Command + +The `run` command works like `time` or `nice` — wrap any command with `capfox run` to check capacity before execution: + +```bash +# Basic usage +capfox run ./script.sh + +# With task name and complexity +capfox run --task ml_training --complexity 100 python train.py + +# With resource estimates +capfox run --cpu 50 --mem 30 ./heavy.sh + +# Quiet mode (no capfox output) +capfox run --quiet ./script.sh + +# Show denial reasons +capfox run --reason ./heavy.sh +``` + +**Exit codes:** +| Code | Meaning | +|------|---------| +| 0-125 | Command's exit code | +| 75 | No capacity available (EX_TEMPFAIL) | +| 126 | Command not executable | +| 127 | Command not found | + ## ⚙️ Configuration ```yaml diff --git a/cmd/capfox/main.go b/cmd/capfox/main.go index d73be56..d6c3c27 100644 --- a/cmd/capfox/main.go +++ b/cmd/capfox/main.go @@ -5,7 +5,7 @@ import ( ) var ( - version = "0.1.0" + version = "0.2.0" ) func main() { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 66c2562..74fd815 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -108,7 +108,7 @@ func TestSetVersion(t *testing.T) { } // Reset - Version = "0.1.0" + Version = "0.2.0" } func TestNewClient(t *testing.T) { diff --git a/internal/cli/root.go b/internal/cli/root.go index e3f1aaf..6f11452 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -18,7 +18,7 @@ var ( password string // Version info (set from main) - Version = "0.1.0" + Version = "0.2.0" ) // rootCmd represents the base command diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..b9ef30f --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,159 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +var runCmd = &cobra.Command{ + Use: "run [flags] [args...]", + Short: "Run command if capacity available", + Long: `Run a command only if server has available capacity. +Works like 'time' or 'nice' - wrap any command with capfox run. + +Exit codes: + 0-125 Command's exit code + 75 No capacity available (command not started) + 126 Command not executable + 127 Command not found`, + Example: ` capfox run ./script.sh + capfox run --task ml python train.py + capfox run --complexity 100 make build + capfox run --cpu 50 --mem 30 ./heavy.sh`, + Args: cobra.MinimumNArgs(1), + RunE: runRun, +} + +var ( + runTask string + runComplexity int + runCPU float64 + runMem float64 + runGPU float64 + runVRAM float64 + runReason bool + runQuiet bool +) + +func init() { + runCmd.Flags().StringVar(&runTask, "task", "", "task name for /ask (default: command name)") + runCmd.Flags().IntVar(&runComplexity, "complexity", 0, "task complexity in parrots") + runCmd.Flags().Float64Var(&runCPU, "cpu", 0, "estimated CPU usage percent") + runCmd.Flags().Float64Var(&runMem, "mem", 0, "estimated memory usage percent") + runCmd.Flags().Float64Var(&runGPU, "gpu", 0, "estimated GPU usage percent") + runCmd.Flags().Float64Var(&runVRAM, "vram", 0, "estimated VRAM usage percent") + runCmd.Flags().BoolVar(&runReason, "reason", false, "show denial reasons") + runCmd.Flags().BoolVar(&runQuiet, "quiet", false, "suppress capfox output") + rootCmd.AddCommand(runCmd) +} + +const ( + exitNoCapacity = 75 // EX_TEMPFAIL from sysexits.h + exitNotExecutable = 126 + exitCommandNotFound = 127 +) + +func runRun(cmd *cobra.Command, args []string) error { + // 1. Determine task name + taskName := runTask + if taskName == "" { + taskName = filepath.Base(args[0]) + } + + // 2. Build ask request + req := askRequest{ + Task: taskName, + Complexity: runComplexity, + } + + // Add resource estimates if provided + if runCPU > 0 || runMem > 0 || runGPU > 0 || runVRAM > 0 { + req.Resources = &resourceEstimate{ + CPU: runCPU, + Memory: runMem, + GPU: runGPU, + VRAM: runVRAM, + } + } + + // 3. Call /ask endpoint + client := NewClient() + + path := "/ask?reason=true" + data, _, err := client.Post(path, req) + if err != nil { + if !runQuiet { + fmt.Fprintf(os.Stderr, "capfox: failed to check capacity: %v\n", err) + } + // If we can't reach the server, still try to run the command + // This is a design decision - fail open + return executeCommand(args) + } + + var resp askResponse + if err := json.Unmarshal(data, &resp); err != nil { + if !runQuiet { + fmt.Fprintf(os.Stderr, "capfox: failed to parse response: %v\n", err) + } + return executeCommand(args) + } + + // 4. If denied, exit with code 75 + if !resp.Allowed { + if !runQuiet { + fmt.Fprintf(os.Stderr, "capfox: denied\n") + if runReason && len(resp.Reasons) > 0 { + for _, r := range resp.Reasons { + fmt.Fprintf(os.Stderr, " - %s\n", r) + } + } + } + os.Exit(exitNoCapacity) + } + + // 5. Notify server about task start (only if complexity is specified) + if runComplexity > 0 { + notifyReq := notifyRequest{ + Task: taskName, + Complexity: runComplexity, + } + // Fire-and-forget: ignore errors + _, _, _ = client.Post("/task/notify", notifyReq) + } + + // 6. If allowed, execute command + if !runQuiet { + fmt.Fprintf(os.Stderr, "capfox: allowed\n") + } + + return executeCommand(args) +} + +func executeCommand(args []string) error { + execCmd := exec.Command(args[0], args[1:]...) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + + err := execCmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + // Check if it's a "not found" error + if execErr, ok := err.(*exec.Error); ok { + if execErr.Err == exec.ErrNotFound { + os.Exit(exitCommandNotFound) + } + } + // For permission denied or not executable + os.Exit(exitNotExecutable) + } + + return nil +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..3e84d29 --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,200 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRunCmd_Exists(t *testing.T) { + if runCmd == nil { + t.Fatal("runCmd should not be nil") + } + + if runCmd.Use != "run [flags] [args...]" { + t.Errorf("unexpected Use: %s", runCmd.Use) + } + + if runCmd.Short != "Run command if capacity available" { + t.Errorf("unexpected Short: %s", runCmd.Short) + } +} + +func TestRunCmd_Flags(t *testing.T) { + tests := []struct { + name string + flagName string + }{ + {"task flag", "task"}, + {"complexity flag", "complexity"}, + {"cpu flag", "cpu"}, + {"mem flag", "mem"}, + {"gpu flag", "gpu"}, + {"vram flag", "vram"}, + {"reason flag", "reason"}, + {"quiet flag", "quiet"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag := runCmd.Flags().Lookup(tt.flagName) + if flag == nil { + t.Errorf("flag %s should exist", tt.flagName) + } + }) + } +} + +func TestExitCodes(t *testing.T) { + if exitNoCapacity != 75 { + t.Errorf("exitNoCapacity should be 75, got %d", exitNoCapacity) + } + if exitNotExecutable != 126 { + t.Errorf("exitNotExecutable should be 126, got %d", exitNotExecutable) + } + if exitCommandNotFound != 127 { + t.Errorf("exitCommandNotFound should be 127, got %d", exitCommandNotFound) + } +} + +func TestTaskNameDerivation(t *testing.T) { + tests := []struct { + name string + taskFlag string + command string + expected string + }{ + {"use flag when provided", "my_task", "/path/to/script.sh", "my_task"}, + {"derive from command basename", "", "/path/to/script.sh", "script.sh"}, + {"derive from simple command", "", "echo", "echo"}, + {"derive from relative path", "", "./script.sh", "script.sh"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the same logic as in runRun + taskName := tt.taskFlag + if taskName == "" { + // This simulates filepath.Base behavior from runRun + taskName = filepath.Base(tt.command) + } + + if taskName != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, taskName) + } + }) + } +} + +func TestExecuteCommand_Success(t *testing.T) { + if os.Getenv("TEST_SUBPROCESS") == "1" { + // This is the subprocess - run executeCommand + err := executeCommand([]string{"true"}) + if err != nil { + os.Exit(1) + } + os.Exit(0) + } + + // Run this test as a subprocess + cmd := exec.Command(os.Args[0], "-test.run=TestExecuteCommand_Success") + cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1") + err := cmd.Run() + + if err != nil { + t.Errorf("command should succeed, got error: %v", err) + } +} + +func TestExecuteCommand_ExitCode(t *testing.T) { + if os.Getenv("TEST_SUBPROCESS") == "1" { + code := os.Getenv("TEST_EXIT_CODE") + // executeCommand will call os.Exit with the command's exit code + _ = executeCommand([]string{"sh", "-c", "exit " + code}) + return + } + + tests := []struct { + name string + exitCode int + }{ + {"exit 0", 0}, + {"exit 1", 1}, + {"exit 42", 42}, + {"exit 125", 125}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestExecuteCommand_ExitCode") + cmd.Env = append(os.Environ(), + "TEST_SUBPROCESS=1", + "TEST_EXIT_CODE="+string(rune('0'+tt.exitCode%10)), + ) + + // For simple cases + if tt.exitCode <= 9 { + cmd.Env = append(os.Environ(), + "TEST_SUBPROCESS=1", + "TEST_EXIT_CODE="+string(byte('0'+tt.exitCode)), + ) + } + + err := cmd.Run() + + if tt.exitCode == 0 { + if err != nil { + t.Errorf("expected no error for exit 0, got %v", err) + } + } else { + if err == nil { + t.Errorf("expected error for exit %d", tt.exitCode) + } + } + }) + } +} + +func TestRunFlags_Default(t *testing.T) { + // Test default values + tests := []struct { + name string + value interface{} + expected interface{} + }{ + {"runTask default", runTask, ""}, + {"runComplexity default", runComplexity, 0}, + {"runCPU default", runCPU, 0.0}, + {"runMem default", runMem, 0.0}, + {"runGPU default", runGPU, 0.0}, + {"runVRAM default", runVRAM, 0.0}, + {"runReason default", runReason, false}, + {"runQuiet default", runQuiet, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Flags are initialized by init(), so defaults should be set + // This tests that the flag definitions are correct + }) + } +} + +func TestRunCmd_MinimumArgs(t *testing.T) { + // Verify that Args requires at least one argument + err := runCmd.Args(runCmd, []string{}) + if err == nil { + t.Error("expected error when no args provided") + } + + err = runCmd.Args(runCmd, []string{"echo"}) + if err != nil { + t.Errorf("expected no error with one arg, got %v", err) + } + + err = runCmd.Args(runCmd, []string{"echo", "hello", "world"}) + if err != nil { + t.Errorf("expected no error with multiple args, got %v", err) + } +}