diff --git a/cmd/grlx/cmd/jobs.go b/cmd/grlx/cmd/jobs.go index 054d8b8..36211f3 100644 --- a/cmd/grlx/cmd/jobs.go +++ b/cmd/grlx/cmd/jobs.go @@ -21,6 +21,7 @@ var ( jobsLimit int jobsLocal bool jobsUser string + jobsCohort string watchTimeout int purgeOlderH int ) @@ -36,6 +37,7 @@ var cmdJobsList = &cobra.Command{ Long: `List recent jobs. By default queries the farmer. Use --local to list jobs from local CLI-side storage. Use --user to filter jobs by the invoking user's pubkey (use 'me' for current user). +Use -C/--cohort to filter jobs to sprouts in a cohort. When using --local, an optional sproutID argument filters jobs for that sprout.`, Run: func(cmd *cobra.Command, args []string) { var summaries []jobs.JobSummary @@ -52,7 +54,24 @@ When using --local, an optional sproutID argument filters jobs for that sprout.` } userFilter = key } - if len(args) > 0 { + if len(args) > 0 && jobsCohort != "" { + log.Fatalf("Cannot use both a sproutID argument and --cohort (-C)") + } + if jobsCohort != "" { + // Resolve cohort to sprout list, then fetch jobs for each. + members, cohortErr := client.ResolveCohort(jobsCohort) + if cohortErr != nil { + log.Fatalf("Failed to resolve cohort %q: %v", jobsCohort, cohortErr) + } + for _, sproutID := range members { + sproutJobs, listErr := client.ListJobsForSprout(sproutID) + if listErr != nil { + log.Errorf("Failed to list jobs for %s: %v", sproutID, listErr) + continue + } + summaries = append(summaries, sproutJobs...) + } + } else if len(args) > 0 { summaries, err = client.ListJobsForSprout(args[0]) } else { summaries, err = client.ListJobs(jobsLimit, userFilter) @@ -503,6 +522,7 @@ func init() { cmdJobsList.Flags().IntVar(&jobsLimit, "limit", 50, "Maximum number of jobs to return") cmdJobsList.Flags().BoolVar(&jobsLocal, "local", false, "List jobs from local CLI-side storage instead of the farmer") cmdJobsList.Flags().StringVar(&jobsUser, "user", "", "Filter jobs by invoking user's pubkey (use 'me' for current user)") + cmdJobsList.Flags().StringVarP(&jobsCohort, "cohort", "C", "", "Filter jobs to sprouts in a cohort") cmdJobsShow.Flags().BoolVar(&jobsLocal, "local", false, "Show job from local CLI-side storage instead of the farmer") cmdJobsWatch.Flags().IntVar(&watchTimeout, "timeout", 120, "Watch timeout in seconds") cmdJobsPurge.Flags().IntVar(&purgeOlderH, "older-than", 720, "Remove jobs older than this many hours (default 720 = 30 days)") diff --git a/cmd/grlx/cmd/sprouts.go b/cmd/grlx/cmd/sprouts.go index 5bb677d..dd7ff2b 100644 --- a/cmd/grlx/cmd/sprouts.go +++ b/cmd/grlx/cmd/sprouts.go @@ -16,6 +16,7 @@ import ( var ( sproutsStateFilter string sproutsOnlineOnly bool + sproutsCohort string ) var cmdSprouts = &cobra.Command{ @@ -36,9 +37,26 @@ var cmdSproutsList = &cobra.Command{ log.Fatalf("Failed to list sprouts: %v", err) } + // If -C/--cohort is given, resolve the cohort and restrict the list + // to only the sprouts in that cohort. + var cohortMembers map[string]bool + if sproutsCohort != "" { + members, cohortErr := client.ResolveCohort(sproutsCohort) + if cohortErr != nil { + log.Fatalf("Failed to resolve cohort %q: %v", sproutsCohort, cohortErr) + } + cohortMembers = make(map[string]bool, len(members)) + for _, m := range members { + cohortMembers[m] = true + } + } + // Apply filters. filtered := sprouts[:0] for _, s := range sprouts { + if cohortMembers != nil && !cohortMembers[s.ID] { + continue + } if sproutsStateFilter != "" && s.KeyState != sproutsStateFilter { continue } @@ -181,6 +199,7 @@ var cmdSproutsShow = &cobra.Command{ func init() { cmdSproutsList.Flags().StringVar(&sproutsStateFilter, "state", "", "Filter by key state (accepted, unaccepted, denied, rejected)") cmdSproutsList.Flags().BoolVar(&sproutsOnlineOnly, "online", false, "Show only online (connected) sprouts") + cmdSproutsList.Flags().StringVarP(&sproutsCohort, "cohort", "C", "", "Filter sprouts to members of a cohort") cmdSprouts.AddCommand(cmdSproutsList) cmdSprouts.AddCommand(cmdSproutsShow) rootCmd.AddCommand(cmdSprouts) diff --git a/cmd/grlx/cmd/targeting_test.go b/cmd/grlx/cmd/targeting_test.go new file mode 100644 index 0000000..5c4e116 --- /dev/null +++ b/cmd/grlx/cmd/targeting_test.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// --- resolveEffectiveTarget tests --- + +func TestResolveEffectiveTarget_TargetOnly_Simple(t *testing.T) { + oldS, oldC := sproutTarget, cohortTarget + defer func() { sproutTarget = oldS; cohortTarget = oldC }() + + sproutTarget = "web-1" + cohortTarget = "" + got, err := resolveEffectiveTarget() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "web-1" { + t.Errorf("got %q, want %q", got, "web-1") + } +} + +func TestResolveEffectiveTarget_TargetOnly_Regex(t *testing.T) { + oldS, oldC := sproutTarget, cohortTarget + defer func() { sproutTarget = oldS; cohortTarget = oldC }() + + sproutTarget = "web-.*" + cohortTarget = "" + got, err := resolveEffectiveTarget() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "web-.*" { + t.Errorf("got %q, want %q", got, "web-.*") + } +} + +func TestResolveEffectiveTarget_TargetOnly_CommaSeparated(t *testing.T) { + oldS, oldC := sproutTarget, cohortTarget + defer func() { sproutTarget = oldS; cohortTarget = oldC }() + + sproutTarget = "web-1,web-2,db-1" + cohortTarget = "" + got, err := resolveEffectiveTarget() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "web-1,web-2,db-1" { + t.Errorf("got %q, want %q", got, "web-1,web-2,db-1") + } +} + +func TestResolveEffectiveTarget_NeitherSet_Error(t *testing.T) { + oldS, oldC := sproutTarget, cohortTarget + defer func() { sproutTarget = oldS; cohortTarget = oldC }() + + sproutTarget = "" + cohortTarget = "" + _, err := resolveEffectiveTarget() + if err == nil { + t.Fatal("expected error when neither flag is set") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("error should mention 'required': %v", err) + } +} + +func TestResolveEffectiveTarget_BothSet_Error(t *testing.T) { + oldS, oldC := sproutTarget, cohortTarget + defer func() { sproutTarget = oldS; cohortTarget = oldC }() + + sproutTarget = "web-1" + cohortTarget = "web-servers" + _, err := resolveEffectiveTarget() + if err == nil { + t.Fatal("expected error when both flags are set") + } + if !strings.Contains(err.Error(), "cannot use both") { + t.Errorf("error should mention 'cannot use both': %v", err) + } +} + +// --- addTargetFlags tests --- + +func TestAddTargetFlags_RegistersFlags(t *testing.T) { + // cook, cmd, and test all use addTargetFlags — verify the flags exist. + cmds := map[string]*cobra.Command{ + "cook": cmdCook, + "cmd": cmdCmd, + "test": testCmd, + } + for name, c := range cmds { + flags := c.PersistentFlags() + if f := flags.Lookup("target"); f == nil { + t.Errorf("%s missing --target (-T) persistent flag", name) + } + if f := flags.Lookup("cohort"); f == nil { + t.Errorf("%s missing --cohort (-C) persistent flag", name) + } + } +} + +func TestCookCommand_HasCohortFlag(t *testing.T) { + f := cmdCook.PersistentFlags().Lookup("cohort") + if f == nil { + t.Fatal("cook command should have --cohort flag") + } + if f.Shorthand != "C" { + t.Errorf("cook --cohort shorthand = %q, want 'C'", f.Shorthand) + } +} + +func TestCmdCommand_HasCohortFlag(t *testing.T) { + f := cmdCmd.PersistentFlags().Lookup("cohort") + if f == nil { + t.Fatal("cmd command should have --cohort flag") + } + if f.Shorthand != "C" { + t.Errorf("cmd --cohort shorthand = %q, want 'C'", f.Shorthand) + } +} + +func TestTestCommand_HasCohortFlag(t *testing.T) { + f := testCmd.PersistentFlags().Lookup("cohort") + if f == nil { + t.Fatal("test command should have --cohort flag") + } + if f.Shorthand != "C" { + t.Errorf("test --cohort shorthand = %q, want 'C'", f.Shorthand) + } +} + +func TestSSHCommand_HasCohortFlag(t *testing.T) { + f := sshCmd.Flags().Lookup("cohort") + if f == nil { + t.Fatal("ssh command should have --cohort flag") + } + if f.Shorthand != "C" { + t.Errorf("ssh --cohort shorthand = %q, want 'C'", f.Shorthand) + } +} + +func TestSproutsListCommand_HasCohortFlag(t *testing.T) { + f := cmdSproutsList.Flags().Lookup("cohort") + if f == nil { + t.Fatal("sprouts list command should have --cohort flag") + } + if f.Shorthand != "C" { + t.Errorf("sprouts list --cohort shorthand = %q, want 'C'", f.Shorthand) + } +} + +func TestJobsListCommand_HasCohortFlag(t *testing.T) { + f := cmdJobsList.Flags().Lookup("cohort") + if f == nil { + t.Fatal("jobs list command should have --cohort flag") + } + if f.Shorthand != "C" { + t.Errorf("jobs list --cohort shorthand = %q, want 'C'", f.Shorthand) + } +} + +// --- cook Use string documents -C --- + +func TestCookUseString_DocumentsCohort(t *testing.T) { + if !strings.Contains(cmdCook.Use, "-C") { + t.Error("cook Use string should mention -C flag") + } + if !strings.Contains(cmdCook.Use, "cohort") { + t.Error("cook Use string should mention 'cohort'") + } +} + +// SSH targeting tests are in ssh_test.go to avoid redeclaration.