diff --git a/cli/cmd/command_profiles.go b/cli/cmd/command_profiles.go new file mode 100644 index 00000000..09ada5f4 --- /dev/null +++ b/cli/cmd/command_profiles.go @@ -0,0 +1,612 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + "sort" + "strings" + "unicode" + + "github.com/bmscomp/kates/cli/output" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/x/ansi" + "github.com/spf13/cobra" +) + +// knownProfiles are the command profiles a context can enable, in the order +// kates lists them. +var knownProfiles = []string{"performance", "integrity", "chaos", "topology", "security", "deploy"} + +// commandProfiles names the profiles that enable each top-level command; a +// subcommand follows its top-level command. A command with none is always +// enabled: the ones every profile needs, kates ctx among them, so a context +// can always change its profiles. A context with no profiles enables every +// command. TestCommandProfilesCoverEveryCommand fails on a command missing +// here. +var commandProfiles = map[string][]string{ + "advisor": {"performance"}, + "audit": nil, + "auto": {"deploy"}, + "badge": {"performance"}, + "benchmark": {"performance"}, + "changelog": nil, + "chaos": {"chaos"}, + "clean": {"deploy"}, + "cluster": {"topology", "chaos"}, + "completion": nil, + "cost": {"performance"}, + "ctx": nil, + "dashboard": {"performance", "integrity"}, + "deploy": {"deploy"}, + "detect": {"deploy"}, + "diff": {"topology"}, + "disruption": {"chaos"}, + "docs": nil, + "doctor": {"deploy", "topology"}, + "explain": {"performance", "integrity"}, + "flow": {"performance", "chaos"}, + "gate": {"performance"}, + "health": nil, + "help": nil, + "init": nil, + "kafka": {"topology", "integrity"}, + "kyverno": {"security", "deploy"}, + "lab": {"performance"}, + "mcp": nil, + "migrate": {"deploy", "integrity"}, + "operator": {"deploy"}, + "operators": {"deploy"}, + "plugin": nil, + "ports": nil, + "profile": {"performance"}, + "replay": {"performance", "integrity"}, + "report": {"performance", "integrity", "chaos"}, + "resilience": {"chaos"}, + "scenario-diff": {"performance", "integrity"}, + "schedule": {"performance", "integrity"}, + "security": {"security"}, + "snapshot": {"topology"}, + "status": nil, + "test": {"performance", "integrity", "chaos"}, + "theme": nil, + "tldr": nil, + "top": {"performance", "integrity"}, + "trend": {"performance"}, + "tune": {"performance"}, + "upgrade": nil, + "version": nil, + "versions": {"deploy"}, + "watch": {"performance", "integrity", "chaos"}, + "webhook": {"performance", "integrity"}, +} + +// activeProfiles returns the profiles in force and where they come from: +// KATES_PROFILES when it is set, else the context's. Unknown names are +// warned about and enable nothing. None, or "all" among them, enables every +// command. +func activeProfiles(ctxName string, ctx Context) (profiles []string, from string) { + names, from := ctx.Profiles, fmt.Sprintf("context %q", ctxName) + if env, ok := os.LookupEnv("KATES_PROFILES"); ok { + names, from = splitProfiles(env), "KATES_PROFILES" + } + if slices.Contains(names, "all") { + return nil, from + } + for _, n := range names { + if !slices.Contains(knownProfiles, n) && !profilesWarned { + fmt.Fprintf(output.Err, " ! Unknown profile %q from %s: kates knows %s\n", n, from, strings.Join(knownProfiles, ", ")) + } + } + profilesWarned = true + // Unknown names stay, so a typo shows wherever the profiles are listed, and + // enable nothing: a context naming only unknown ones runs the + // always-enabled commands alone, not every command. + profiles = inProfileOrder(names) + for _, n := range names { + if !slices.Contains(knownProfiles, n) && !slices.Contains(profiles, n) { + profiles = append(profiles, n) + } + } + return profiles, from +} + +// activeProfileNames and activeProfilesFrom are what the pre-run's +// activeProfiles returned, for commands that list other commands (kates tldr). +var ( + activeProfileNames []string + activeProfilesFrom string +) + +// profilesWarned keeps activeProfiles' warnings to one round a process: the +// help and kates ctx profiles read the profiles again after the pre-run. +var profilesWarned bool + +func splitProfiles(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) +} + +// inProfileOrder returns names in knownProfiles' order, once each. +func inProfileOrder(names []string) []string { + var out []string + for _, p := range knownProfiles { + if slices.Contains(names, p) { + out = append(out, p) + } + } + return out +} + +// commandEnabled reports whether the top-level command name runs under +// profiles. Plugins and cobra's __complete are not in commandProfiles, and +// always run. +func commandEnabled(name string, profiles []string) bool { + needs := commandProfiles[name] + return len(profiles) == 0 || len(needs) == 0 || + slices.ContainsFunc(needs, func(p string) bool { return slices.Contains(profiles, p) }) +} + +// topLevel is cmd's ancestor just below the root, or cmd itself. +func topLevel(cmd *cobra.Command) *cobra.Command { + for cmd.HasParent() && cmd.Parent().HasParent() { + cmd = cmd.Parent() + } + return cmd +} + +// profileRefusal prints why cmd may not run under profiles, with the two ways +// to run it, and returns the error that exits 1; nil when it may run. +func profileRefusal(cmd *cobra.Command, ctxName string, profiles []string, from string) error { + top := topLevel(cmd) + if commandEnabled(top.Name(), profiles) { + return nil + } + err := cmdErr(fmt.Sprintf("kates %s needs the %s profile", top.Name(), orList(commandProfiles[top.Name()]))) + profileFix(cmd, ctxName, profiles, from) + return err +} + +// profileFix prints on stderr, where the error goes, the profiles in force and +// the two ways to run cmd, so that a refusal under -o json leaves stdout empty. +func profileFix(cmd *cobra.Command, ctxName string, profiles []string, from string) { + needs := commandProfiles[topLevel(cmd).Name()] + enable := "kates ctx profiles set " + strings.Join(inProfileOrder(append(slices.Clone(profiles), needs[0])), " ") + contextArg(ctxName) + if from == "KATES_PROFILES" { + enable = "add " + needs[0] + " to KATES_PROFILES" + } + for _, line := range []string{ + "Enabled: " + strings.Join(profiles, ", ") + " (" + from + ")", + "Enable it: " + enable, + "Run once: KATES_PROFILES=all " + cmd.CommandPath() + contextArg(ctxName) + " " + ellipsis(), + } { + fmt.Fprintln(output.Err, output.DimStyle.Render(" "+line)) + } +} + +// contextArg is " --context " when --context chose the context, so a +// command printed for the user changes that context, not the current one. +func contextArg(ctxName string) string { + if contextFlag != "" && !contextFromEnv { + return " --context " + ctxName + } + return "" +} + +// orList joins names as "a", "a or b", "a, b or c". +func orList(names []string) string { + if len(names) < 2 { + return strings.Join(names, "") + } + return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1] +} + +// hideDisabledCommands keeps shell completion from offering the top-level +// commands profiles do not enable. +func hideDisabledCommands(root *cobra.Command, profiles []string) { + for _, c := range root.Commands() { + if !commandEnabled(c.Name(), profiles) { + c.Hidden = true + } + } +} + +// profileHelp is the root help: long with lines naming the profiles, and +// where they come from, before Quick Start, and without the lines of commands +// they do not enable, nor a section left with none. With no profiles it is +// long as written. +func profileHelp(long, ctxName string, profiles []string, from string) string { + if len(profiles) == 0 { + return long + } + change := "kates ctx profiles set" + contextArg(ctxName) + if from == "KATES_PROFILES" { + change = "edit or unset KATES_PROFILES" + } + // The values start where the help's descriptions do. + header := fmt.Sprintf(" %-15s%s (%s)\n %-15s%s\n %-15s%s\n\n", + "Profiles:", strings.Join(profiles, ", "), from, "Change them:", change, "Every command:", "KATES_PROFILES=all kates") + var blocks []string + for _, block := range strings.Split(long, "\n\n") { + lines := strings.Split(block, "\n") + // Only a section's lines start with a command; the banner's words may + // name one too. + if !strings.HasSuffix(lines[0], ":") || strings.HasPrefix(lines[0], " ") { + blocks = append(blocks, block) + continue + } + kept := lines[:1] + for _, l := range lines[1:] { + word := strings.Fields(strings.TrimPrefix(strings.TrimSpace(l), "$ kates ")) + if len(word) > 0 && !commandEnabled(word[0], profiles) { + continue + } + kept = append(kept, l) + } + if len(kept) > 1 { + blocks = append(blocks, strings.Join(kept, "\n")) + } + } + help := strings.Join(blocks, "\n\n") + if i := strings.Index(help, "Quick Start:"); i >= 0 { + help = help[:i] + header + help[i:] + } + return help +} + +// profileCommands are the top-level commands profile enables, sorted; for +// "", the ones always enabled. +func profileCommands(profile string) []string { + var names []string + for name, needs := range commandProfiles { + if (profile == "" && len(needs) == 0) || (profile != "" && slices.Contains(needs, profile)) { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +var ctxProfilesCmd = &cobra.Command{ + Use: "profiles [profile...|all]", + Short: "Show the command profiles a context enables", + Long: `Show which command profiles the current context enables, or the one +--context names, and the commands each profile enables. Name profiles, or all, +to list their commands with what each one does. + +A context with profiles enables only their commands, plus the ones every +profile needs (kates ctx, health, help and a few more). kates lists only those, +and refuses the others with the command that enables them. A context with no +profiles enables every command. KATES_PROFILES overrides the context's +profiles for one call; KATES_PROFILES=all enables every command.`, + Example: ` kates ctx profiles + kates ctx profiles chaos topology + kates ctx profiles all + kates ctx profiles --context lab -o json`, + ValidArgsFunction: completeProfiles, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := profilesContext() + if err != nil { + return err + } + shown := knownProfiles + if len(args) > 0 { + named, err := parseProfiles(args) + if err != nil { + return cmdErr(err.Error()) + } + if named != nil { + shown = named + } + } + active, from := activeProfiles(activeContext, ctx) + short := map[string]string{} + for _, c := range cmd.Root().Commands() { + short[c.Name()] = c.Short + } + + if outputMode == "json" { + available := make([]map[string]any, 0, len(shown)) + for _, p := range shown { + commands := []map[string]any{} + for _, c := range profileCommands(p) { + commands = append(commands, map[string]any{"name": c, "description": short[c], "alsoIn": alsoIn(c, p)}) + } + available = append(available, map[string]any{"profile": p, "enabled": slices.Contains(active, p), "commands": commands}) + } + source := "context" + if from == "KATES_PROFILES" { + source = from + } + output.JSON(map[string]any{ + "context": activeContext, "profiles": nonNil(active), "from": source, + "available": available, "alwaysEnabled": profileCommands(""), + }) + return nil + } + + output.Header("Command Profiles") + // The values start where the grid's commands do. + key := func(k, v string) { + fmt.Fprintf(output.Out, " %s%s\n", output.KeyStyle.Width(profileIndent-2).Render(k), output.ValueStyle.Render(v)) + } + key("Context", activeContext) + if len(active) == 0 { + key("Profiles", "none, every command is enabled") + } else { + key("Profiles", strings.Join(active, ", ")) + } + if from == "KATES_PROFILES" { + output.Hint(" KATES_PROFILES is set, and overrides the context's profiles in this shell.") + } + fmt.Fprintln(output.Out) + // Hint indents by 2, so the commands start where the grid's do. + hint := func(label, command string) { + output.Hint(fmt.Sprintf(" %-*s%s%s", profileIndent-4, label, command, contextArg(activeContext))) + } + if len(args) == 0 { + printProfileGrid(active) + hint("Details:", "kates ctx profiles ...") + } else { + for _, p := range shown { + printProfileDetails(p, active, short) + } + } + hint("Change them:", "kates ctx profiles set [profile...]") + hint("Every command:", "kates ctx profiles set all") + return nil + }, +} + +// The profile list's layout: " ● ", the profile name, a gap, and then the +// commands, in cells as wide as the longest command name and two spaces, so +// that every profile's commands line up in the same columns. +const ( + profileNameWidth = 13 + profileIndent = 4 + profileNameWidth + 4 +) + +func commandCellWidth() int { + w := 0 + for name := range commandProfiles { + w = max(w, len(name)) + } + return w + 2 +} + +// printProfileGrid prints each profile with its commands in a grid, the +// enabled commands highlighted and the refused ones dimmed. +func printProfileGrid(active []string) { + cell := commandCellWidth() + cols := max(1, (output.ColumnWidth(profileIndent, cell)+2)/cell) // the last cell needs no gap + block := func(lead string, commands []string) { + for i, row := range slices.Collect(slices.Chunk(commands, cols)) { + if i > 0 { + lead = strings.Repeat(" ", profileIndent) + } + var b strings.Builder + b.WriteString(lead) + for _, c := range row { + b.WriteString(styleCommand(c, active) + strings.Repeat(" ", cell-len(c))) + } + fmt.Fprintln(output.Out, strings.TrimRight(b.String(), " ")) + } + fmt.Fprintln(output.Out) + } + for _, p := range knownProfiles { + block(profileLead(p, active), profileCommands(p)) + } + block(" "+output.DimStyle.Render(output.PadRight("always", profileNameWidth))+" ", profileCommands("")) +} + +// printProfileDetails prints a profile's commands one a line, aligned with +// the grid, with what each does and the other profiles that enable it. +func printProfileDetails(profile string, active []string, short map[string]string) { + state := "not enabled" + switch { + case slices.Contains(active, profile): + state = "enabled" + case len(active) == 0: + state = "enabled, as every command is" + } + fmt.Fprintln(output.Out, profileLead(profile, active)+output.DimStyle.Render(state)) + cell := commandCellWidth() + for _, c := range profileCommands(profile) { + note := "" + if also := alsoIn(c, profile); len(also) > 0 { + note = output.DimStyle.Render(" (also " + strings.Join(also, ", ") + ")") + } + // The description is cut to the terminal, not the other profiles. + room := max(10, output.TermWidth()-profileIndent-cell-ansi.StringWidth(note)) + fmt.Fprintln(output.Out, strings.Repeat(" ", profileIndent)+styleCommand(c, active)+strings.Repeat(" ", cell-len(c))+ + ansi.Truncate(short[c], room, ellipsis())+note) + } + fmt.Fprintln(output.Out) +} + +// profileLead is a profile's marker and name, padded to where its commands +// start. A selected profile stands out; the others are dimmed when some are +// selected. +func profileLead(profile string, active []string) string { + marker, name := output.DimStyle.Render(output.Glyphs().Ring), output.PadRight(profile, profileNameWidth) + switch { + case slices.Contains(active, profile): + marker, name = output.SuccessStyle.Render(output.Glyphs().Bullet), output.SuccessStyle.Bold(true).Render(name) + case len(active) > 0: + name = output.DimStyle.Render(name) + } + return " " + marker + " " + name + " " +} + +// styleCommand highlights a command the profiles enable, and dims one they +// refuse. +func styleCommand(name string, active []string) string { + if commandEnabled(name, active) { + return output.AccentStyle.Render(name) + } + return output.DimStyle.Render(name) +} + +// alsoIn are the profiles other than profile that enable command. +func alsoIn(command, profile string) []string { + return slices.DeleteFunc(slices.Clone(commandProfiles[command]), func(p string) bool { return p == profile }) +} + +// ellipsis is "…", or "..." where the output must stay ASCII. +func ellipsis() string { + if output.ASCII() { + return "..." + } + return "…" +} + +var ctxProfilesSetCmd = &cobra.Command{ + Use: "set [profile...|all]", + Short: "Choose the command profiles a context enables", + Long: `Set the command profiles of the current context, or of the one --context +names. The profiles given replace the ones it had; all enables every command. +With no profile named, in a terminal, it asks which ones to enable. + +Profiles: ` + strings.Join(knownProfiles, ", ") + `.`, + Example: ` kates ctx profiles set chaos topology + kates ctx profiles set performance --context lab + kates ctx profiles set all + kates ctx profiles set # choose from a list`, + ValidArgsFunction: completeProfiles, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := profilesContext() + if err != nil { + return err + } + names := args + if len(names) == 0 { + if !IsInteractive() { + return cmdErr("name the profiles to enable (" + strings.Join(knownProfiles, ", ") + "), or all") + } + if names, err = pickProfiles(ctx.Profiles); err != nil { + return err + } + } + profiles, err := parseProfiles(names) + if err != nil { + return cmdErr(err.Error()) + } + + err = updateConfig(func(c *Config) error { + stored, ok := c.Contexts[activeContext] + if !ok { + return fmt.Errorf("context %q is not in %s", activeContext, configPath()) + } + if slices.Equal(stored.Profiles, profiles) { + return errConfigUnchanged + } + stored.Profiles = profiles + c.Contexts[activeContext] = stored + return nil + }) + if err != nil { + return cmdErr("Failed to save: " + err.Error()) + } + if len(profiles) == 0 { + output.Success(fmt.Sprintf("Context '%s' enables every command", activeContext)) + } else { + output.Success(fmt.Sprintf("Context '%s' enables %s", activeContext, strings.Join(profiles, ", "))) + } + if _, ok := os.LookupEnv("KATES_PROFILES"); ok { + output.Hint(" KATES_PROFILES is set, and overrides these in this shell.") + } + return nil + }, +} + +// profilesContext is the context kates ctx profiles shows or changes: the one +// --context or KATES_CONTEXT names, else the current one. It must be in the +// config, which holds the built-in default when there is no file yet. +func profilesContext() (Context, error) { + cfg, err := readConfig() + if err != nil { + return Context{}, cmdErr(fmt.Sprintf("Cannot load %s: %v", configPath(), err)) + } + _, ctx, err := resolveContext(cfg) + if err != nil { + return Context{}, cmdErr(err.Error()) + } + return ctx, nil +} + +// parseProfiles checks names, each a profile or a comma-separated list of +// them, against knownProfiles. all, alone, enables every command and is +// stored as none. +func parseProfiles(names []string) ([]string, error) { + var profiles []string + for _, n := range names { + profiles = append(profiles, splitProfiles(n)...) + } + if len(profiles) == 0 { + return nil, fmt.Errorf("name a profile (%s), or all", strings.Join(knownProfiles, ", ")) + } + if slices.Contains(profiles, "all") { + if len(profiles) > 1 { + return nil, fmt.Errorf("all enables every command; name it alone") + } + return nil, nil + } + for _, p := range profiles { + if !slices.Contains(knownProfiles, p) { + return nil, fmt.Errorf("unknown profile %q; kates knows %s, and all", p, strings.Join(knownProfiles, ", ")) + } + } + return inProfileOrder(profiles), nil +} + +// pickProfiles asks which profiles to enable, the current ones ticked. None +// ticked means every command. +func pickProfiles(current []string) ([]string, error) { + // One line an option, cut to the terminal after huh's "┃ ▸ ✓ ": the name, + // how many commands it enables, and their names. + // Ticked profiles first: huh scrolls the list to the first ticked option, + // which hid the ones above it. + ticked := inProfileOrder(current) + order := append(slices.Clone(ticked), slices.DeleteFunc(slices.Clone(knownProfiles), func(p string) bool { return slices.Contains(ticked, p) })...) + options := make([]huh.Option[string], 0, len(order)) + for _, p := range order { + commands := profileCommands(p) + label := fmt.Sprintf("%s%2d commands %s", output.PadRight(p, profileNameWidth), len(commands), strings.Join(commands, ", ")) + options = append(options, huh.NewOption(ansi.Truncate(label, output.ColumnWidth(7, 30), ellipsis()), p). + Selected(slices.Contains(ticked, p))) + } + var picked []string + err := huh.NewForm(huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Command profiles for context " + activeContext). + Description("Space or x toggles, enter saves. None ticked enables every command."). + Options(options...). + Value(&picked), + )).WithTheme(ThemeKates()).Run() + if err != nil { + return nil, err + } + if len(picked) == 0 { + return []string{"all"}, nil + } + return picked, nil +} + +// completeProfiles completes the profile names args has not named yet, and +// all. +func completeProfiles(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + var out []cobra.Completion + for _, p := range knownProfiles { + if !slices.Contains(args, p) { + out = append(out, cobra.CompletionWithDesc(p, strings.Join(profileCommands(p), ", "))) + } + } + if len(args) == 0 { // all stands alone + out = append(out, cobra.CompletionWithDesc("all", "every command")) + } + return out, cobra.ShellCompDirectiveNoFileComp +} + +func init() { + ctxProfilesCmd.AddCommand(ctxProfilesSetCmd) + ctxCmd.AddCommand(ctxProfilesCmd) +} diff --git a/cli/cmd/command_profiles_test.go b/cli/cmd/command_profiles_test.go new file mode 100644 index 00000000..d22a0619 --- /dev/null +++ b/cli/cmd/command_profiles_test.go @@ -0,0 +1,493 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + "testing" + + "github.com/bmscomp/kates/cli/output" + "github.com/spf13/cobra" +) + +// Every top-level command must say which profiles enable it, so a new +// command cannot slip past them. +func TestCommandProfilesCoverEveryCommand(t *testing.T) { + registered := map[string]bool{} + for _, c := range rootCmd.Commands() { + if c.Annotations[pluginAnnotation] != "" || strings.HasPrefix(c.Name(), "__") { + continue + } + registered[c.Name()] = true + if _, ok := commandProfiles[c.Name()]; !ok { + t.Errorf("kates %s is not in commandProfiles", c.Name()) + } + } + for name, profiles := range commandProfiles { + // cobra adds help and completion only when kates runs. + if !registered[name] && name != "help" && name != "completion" { + t.Errorf("commandProfiles names %q, which is not a kates command", name) + } + for _, p := range profiles { + if !slices.Contains(knownProfiles, p) { + t.Errorf("kates %s names unknown profile %q", name, p) + } + } + } +} + +func TestActiveProfiles(t *testing.T) { + for _, tt := range []struct { + name, env string + setEnv bool + stored []string + want []string + wantFrom, wantErr string + }{ + {name: "none enables everything", wantFrom: `context "lab"`}, + {name: "stored, in profile order", stored: []string{"deploy", "chaos"}, want: []string{"chaos", "deploy"}, wantFrom: `context "lab"`}, + {name: "unknown kept, after the known, with a warning", stored: []string{"chaso", "chaos"}, want: []string{"chaos", "chaso"}, wantErr: `Unknown profile "chaso"`}, + {name: "only unknown names enable nothing", stored: []string{"perf"}, want: []string{"perf"}, wantErr: `Unknown profile "perf"`}, + {name: "all enables everything", stored: []string{"chaos", "all"}}, + {name: "KATES_PROFILES overrides", stored: []string{"chaos"}, setEnv: true, env: "topology, security", + want: []string{"topology", "security"}, wantFrom: "KATES_PROFILES"}, + {name: "empty KATES_PROFILES enables everything", stored: []string{"chaos"}, setEnv: true, wantFrom: "KATES_PROFILES"}, + } { + t.Run(tt.name, func(t *testing.T) { + if tt.setEnv { + t.Setenv("KATES_PROFILES", tt.env) + } else { + unsetEnv(t, "KATES_PROFILES") + } + prevOut, prevErr, prevWarned := output.Out, output.Err, profilesWarned + t.Cleanup(func() { output.Out, output.Err, profilesWarned = prevOut, prevErr, prevWarned }) + buf := output.ResetForTesting() + profilesWarned = false + + got, from := activeProfiles("lab", Context{Profiles: tt.stored}) + _, _ = activeProfiles("lab", Context{Profiles: tt.stored}) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("profiles = %q, want %q", got, tt.want) + } + if tt.wantFrom != "" && from != tt.wantFrom { + t.Errorf("from = %q, want %q", from, tt.wantFrom) + } + if !strings.Contains(buf.String(), tt.wantErr) { + t.Errorf("stderr %q lacks %q", buf.String(), tt.wantErr) + } + if tt.wantErr != "" && strings.Count(buf.String(), tt.wantErr) != 1 { + t.Errorf("warned %d times, want once: %q", strings.Count(buf.String(), tt.wantErr), buf.String()) + } + }) + } +} + +func TestProfileRefusal(t *testing.T) { + find := func(args ...string) *cobra.Command { + c, _, err := rootCmd.Find(args) + if err != nil { + t.Fatal(err) + } + return c + } + for _, tt := range []struct { + name string + cmd []string + profiles []string + from string + flag string + wantRefer []string // empty: the command may run + }{ + {name: "no profiles run everything", cmd: []string{"tune", "types"}}, + {name: "an enabled command runs", cmd: []string{"disruption", "run"}, profiles: []string{"chaos"}}, + {name: "one of several profiles is enough", cmd: []string{"test", "create"}, profiles: []string{"integrity"}}, + {name: "always enabled", cmd: []string{"ctx", "profiles", "set"}, profiles: []string{"chaos"}}, + {name: "a subcommand follows its parent", cmd: []string{"tune", "types"}, profiles: []string{"chaos", "deploy"}, + wantRefer: []string{ + "kates tune needs the performance profile\n", + `Enabled: chaos, deploy (context "lab")`, + "Enable it: kates ctx profiles set performance chaos deploy", + "Run once: KATES_PROFILES=all kates tune types", + }}, + {name: "several profiles named", cmd: []string{"kafka", "topics"}, profiles: []string{"chaos"}, + wantRefer: []string{"kates kafka needs the topology or integrity profile"}}, + {name: "--context is kept in the fix", cmd: []string{"security", "audit"}, profiles: []string{"chaos"}, flag: "lab", + wantRefer: []string{"kates ctx profiles set chaos security --context lab", "KATES_PROFILES=all kates security audit --context lab"}}, + {name: "only unknown profiles", cmd: []string{"tune", "types"}, profiles: []string{"perf"}, + wantRefer: []string{"Enabled: perf (context", "Enable it: kates ctx profiles set performance\n"}}, + {name: "from KATES_PROFILES", cmd: []string{"deploy"}, profiles: []string{"chaos"}, from: "KATES_PROFILES", + wantRefer: []string{"Enabled: chaos (KATES_PROFILES)", "Enable it: add deploy to KATES_PROFILES\n"}}, + } { + t.Run(tt.name, func(t *testing.T) { + prevFlag, prevEnv, prevOut, prevErr := contextFlag, contextFromEnv, output.Out, output.Err + t.Cleanup(func() { contextFlag, contextFromEnv, output.Out, output.Err = prevFlag, prevEnv, prevOut, prevErr }) + contextFlag, contextFromEnv = tt.flag, false + buf := output.ResetForTesting() + var stdout bytes.Buffer + output.Out = &stdout + from := tt.from + if from == "" { + from = `context "lab"` + } + + err := profileRefusal(find(tt.cmd...), "lab", tt.profiles, from) + if len(tt.wantRefer) == 0 { + if err != nil { + t.Fatalf("refused: %v\n%s", err, buf) + } + return + } + if err == nil { + t.Fatal("ran; want it refused") + } + for _, want := range tt.wantRefer { + if out := stripAnsi(buf.String()); !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + if stdout.Len() > 0 { + t.Errorf("a refusal wrote to stdout, which -o json reads: %q", stdout.String()) + } + }) + } +} + +func TestProfileHelp(t *testing.T) { + prevFlag, prevEnv := contextFlag, contextFromEnv + t.Cleanup(func() { contextFlag, contextFromEnv = prevFlag, prevEnv }) + contextFlag, contextFromEnv = "", false + all := profileHelp(helpTemplate, "lab", nil, `context "lab"`) + if all != helpTemplate { + t.Errorf("help without profiles is not the help as written:\n%s", all) + } + + chaos := profileHelp(helpTemplate, "lab", []string{"chaos"}, `context "lab"`) + for _, want := range []string{ + "\n Profiles: chaos (context \"lab\")\n Change them: kates ctx profiles set\n Every command: KATES_PROFILES=all kates\n", + "trend analysis for Apache Kafka", " disruption ", " test create ", + " ctx profiles ", " KATES_PROFILES ", "$ kates health", "Flags:", + } { + if !strings.Contains(chaos, want) { + t.Errorf("chaos help lacks %q", want) + } + } + for _, gone := range []string{" tune ", " lab ", "$ kates lab", "Security:", "Deployment:", " kyverno "} { + if strings.Contains(chaos, gone) { + t.Errorf("chaos help still has %q", gone) + } + } + + env := profileHelp(helpTemplate, "lab", []string{"deploy"}, "KATES_PROFILES") + if !strings.Contains(env, "Profiles: deploy (KATES_PROFILES)") || !strings.Contains(env, "edit or unset KATES_PROFILES") || + strings.Contains(env, `context "lab"`) { + t.Errorf("help credits KATES_PROFILES to the context:\n%s", env) + } + + contextFlag = "sec" + if other := profileHelp(helpTemplate, "sec", []string{"chaos"}, `context "sec"`); !strings.Contains(other, "kates ctx profiles set --context sec") { + t.Errorf("help under --context points at the current context:\n%s", other) + } + for _, help := range []string{all, chaos, env} { + header := help[:strings.Index(help, "Quick Start:")] + for _, line := range strings.Split(header, "\n") { + if w := len([]rune(line)); w > 80 { + t.Errorf("header line is %d columns: %q", w, line) + } + } + } +} + +func TestCtxProfilesSet(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + unsetEnv(t, "KATES_PROFILES") + prevActive, prevURL, prevKey, prevOut, prevErr := activeContext, ctxSetURL, ctxSetAPIKey, output.Out, output.Err + t.Cleanup(func() { + activeContext, ctxSetURL, ctxSetAPIKey, output.Out, output.Err = prevActive, prevURL, prevKey, prevOut, prevErr + }) + output.ResetForTesting() + mustSaveConfig(t, Config{CurrentContext: "lab", Contexts: map[string]Context{ + "lab": {URL: "http://localhost:30083", APIKey: "lab-key"}, + }}) + activeContext = "lab" + stored := func() Context { return loadConfig().Contexts["lab"] } + + if err := ctxProfilesSetCmd.RunE(ctxProfilesSetCmd, []string{"topology,chaos"}); err != nil { + t.Fatal(err) + } + if got := stored(); !reflect.DeepEqual(got.Profiles, []string{"chaos", "topology"}) || got.APIKey != "lab-key" { + t.Errorf("after set = %+v, want chaos, topology and the key kept", got) + } + + for _, bad := range [][]string{{"chaso"}, {"all", "chaos"}, {""}, {","}} { + if err := ctxProfilesSetCmd.RunE(ctxProfilesSetCmd, bad); err == nil { + t.Errorf("set %q succeeded; want it refused", bad) + } + } + if got := stored().Profiles; !reflect.DeepEqual(got, []string{"chaos", "topology"}) { + t.Errorf("a refused set changed the profiles to %q", got) + } + + ctxSetURL, ctxSetAPIKey = "http://localhost:8080", "" + if err := ctxSetCmd.RunE(ctxSetCmd, []string{"lab"}); err != nil { + t.Fatal(err) + } + if got := stored().Profiles; !reflect.DeepEqual(got, []string{"chaos", "topology"}) { + t.Errorf("kates ctx set dropped the profiles: %q", got) + } + + if err := ctxProfilesSetCmd.RunE(ctxProfilesSetCmd, []string{"all"}); err != nil { + t.Fatal(err) + } + if got := stored().Profiles; got != nil { + t.Errorf("after set all = %q, want none", got) + } + + activeContext = "gone" + if err := ctxProfilesSetCmd.RunE(ctxProfilesSetCmd, []string{"chaos"}); err == nil { + t.Error("set on a context the config lacks succeeded") + } +} + +func TestProfilesRefuseAndHideEndToEnd(t *testing.T) { + out, code, requests := runKatesCLI(t, []string{"KATES_PROFILES=chaos"}, "test", "list") + if code != 0 || requests == 0 { + t.Errorf("kates test list under chaos: exit %d, %d requests\n%s", code, requests, out) + } + out, code, requests = runKatesCLI(t, []string{"KATES_PROFILES=chaos"}, "tune", "types") + if code != 1 || requests != 0 || !strings.Contains(out, "kates tune needs the performance profile") { + t.Errorf("kates tune types under chaos: exit %d, %d requests\n%s", code, requests, out) + } + + out, code, _ = runKatesCLI(t, []string{"KATES_PROFILES=chaos"}, "tune", "--help") + out = stripAnsi(out) + if code != 0 || !strings.Contains(out, "Available Commands:") || !strings.Contains(out, "! kates tune needs the performance profile") { + t.Errorf("kates tune --help under chaos: exit %d\n%s", code, out) + } + + out, code, _ = runKatesCLI(t, []string{"KATES_PROFILES=performance"}, "tldr") + out = stripAnsi(out) + if code != 0 || strings.Contains(out, "kates kafka") || strings.Contains(out, "Kafka Operations") || + !strings.Contains(out, "kates report diff") || !strings.Contains(out, "Profiles: performance (KATES_PROFILES)") || + !strings.Contains(out, "\n Quick Start\n") { + t.Errorf("kates tldr under performance: exit %d\n%s", code, out) + } + + out, code, _ = runKatesCLI(t, []string{"KATES_PROFILES=topology"}, "__complete", "t") + if code != 0 { + t.Fatalf("completion: exit %d\n%s", code, out) + } + offered := map[string]bool{} + for _, line := range strings.Split(out, "\n") { + offered[strings.SplitN(line, "\t", 2)[0]] = true + } + if !offered["tldr"] || offered["tune"] || offered["test"] { + t.Errorf("completion under topology offered:\n%s", out) + } + + // Completion hides by the context the command line names. + home := t.TempDir() + config := "current-context: lab\ncontexts:\n lab:\n url: http://127.0.0.1:9\n profiles: [chaos]\n" + + " sec:\n url: http://127.0.0.1:9\n profiles: [security]\n" + if err := os.WriteFile(filepath.Join(home, ".kates.yaml"), []byte(config), 0o600); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + args []string + want bool + }{ + {[]string{"__complete", "sec"}, false}, + {[]string{"__complete", "--context", "sec", "sec"}, true}, + {[]string{"__complete", "--context=sec", "sec"}, true}, + } { + out, _, _ := runKatesCLI(t, []string{"KATES_TEST_HOME=" + home}, tt.args...) + if got := strings.Contains(out, "security"); got != tt.want { + t.Errorf("kates %s offered security = %v, want %v:\n%s", strings.Join(tt.args, " "), got, tt.want, out) + } + } +} + +// unsetEnv unsets key for the test and restores it afterwards. +func unsetEnv(t *testing.T, key string) { + t.Helper() + t.Setenv(key, "") + os.Unsetenv(key) +} + +// runCtxProfiles runs kates ctx profiles with args against a config whose +// context lab enables chaos and topology, and returns its output, colours +// stripped. +func runCtxProfiles(t *testing.T, mode string, args ...string) (string, error) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + unsetEnv(t, "KATES_PROFILES") + prevActive, prevMode, prevOut, prevErr := activeContext, outputMode, output.Out, output.Err + t.Cleanup(func() { activeContext, outputMode, output.Out, output.Err = prevActive, prevMode, prevOut, prevErr }) + mustSaveConfig(t, Config{CurrentContext: "lab", Contexts: map[string]Context{ + "lab": {URL: "http://localhost:30083", Profiles: []string{"chaos", "topology"}}, + }}) + activeContext, outputMode = "lab", mode + buf := output.ResetForTesting() + err := ctxProfilesCmd.RunE(ctxProfilesCmd, args) + return stripAnsi(buf.String()), err +} + +// The commands of every profile line up in the same columns: each cell +// starts at profileIndent plus a whole number of cells, and holds one command. +func TestCtxProfilesLayout(t *testing.T) { + out, err := runCtxProfiles(t, "") + if err != nil { + t.Fatal(err) + } + cell, seen := commandCellWidth(), map[string]int{} + lead := regexp.MustCompile(`^ (. \S+| always) +$`) + for _, line := range strings.Split(out, "\n") { + r := []rune(line) + if len(r) <= profileIndent || !lead.MatchString(string(r[:profileIndent])) && strings.TrimSpace(string(r[:profileIndent])) != "" { + continue + } + if label := strings.TrimSpace(line); strings.HasPrefix(label, "Details:") || strings.HasPrefix(label, "Change") || strings.HasPrefix(label, "Every") { + if i := strings.Index(line, "kates ctx"); i != profileIndent { + t.Errorf("hint command starts at column %d, the grid at %d: %q", i, profileIndent, line) + } + continue + } + rest := r[profileIndent:] + for start := 0; start < len(rest); start += cell { + text := strings.TrimSpace(string(rest[start:min(start+cell, len(rest))])) + if rest[start] == ' ' || commandProfiles[text] == nil && !slices.Contains(profileCommands(""), text) { + t.Errorf("cell at column %d of %q is %q, not a command starting there", profileIndent+start, line, text) + } + seen[text]++ + } + } + for _, p := range append(slices.Clone(knownProfiles), "") { + for _, c := range profileCommands(p) { + if seen[c] == 0 { + t.Errorf("%s is missing from the profile list", c) + } + } + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, " Context ") && strings.Index(line, "lab") != profileIndent { + t.Errorf("the Context value starts at column %d, the grid at %d: %q", strings.Index(line, "lab"), profileIndent, line) + } + } + on, off := output.Glyphs().Bullet, output.Glyphs().Ring + for _, want := range []string{" " + on + " chaos ", " " + on + " topology ", " " + off + " performance ", " always "} { + if !strings.Contains(out, want) { + t.Errorf("profile list lacks %q:\n%s", want, out) + } + } +} + +func TestCtxProfilesDetails(t *testing.T) { + out, err := runCtxProfiles(t, "", "topology") + if err != nil { + t.Fatal(err) + } + pad := strings.Repeat(" ", profileIndent) + for _, want := range []string{ + " " + output.Glyphs().Bullet + " topology enabled", + pad + "cluster" + strings.Repeat(" ", commandCellWidth()-len("cluster")) + clusterCmd.Short[:10], + pad + "snapshot ", + } { + if !strings.Contains(out, want) { + t.Errorf("details lack %q:\n%s", want, out) + } + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, pad+"cluster ") && !strings.HasSuffix(line, " (also chaos)") { + t.Errorf("the cluster line lost its other profiles: %q", line) + } + } + if strings.Contains(out, "performance") { + t.Errorf("details of topology show another profile:\n%s", out) + } + if _, err := runCtxProfiles(t, "", "chaso"); err == nil { + t.Error("an unknown profile was shown") + } + + out, err = runCtxProfiles(t, "json", "topology") + if err != nil { + t.Fatal(err) + } + var got struct { + Profiles []string `json:"profiles"` + Available []struct { + Profile string `json:"profile"` + Enabled bool `json:"enabled"` + Commands []struct { + Name, Description string + AlsoIn []string `json:"alsoIn"` + } `json:"commands"` + } `json:"available"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out) + } + if len(got.Available) != 1 || got.Available[0].Profile != "topology" || !got.Available[0].Enabled || + got.Available[0].Commands[0].Name != "cluster" || !reflect.DeepEqual(got.Available[0].Commands[0].AlsoIn, []string{"chaos"}) || + got.Available[0].Commands[0].Description != clusterCmd.Short { + t.Errorf("JSON = %+v", got) + } +} + +func TestCompleteProfilesOffersAllAlone(t *testing.T) { + has := func(out []cobra.Completion, name string) bool { + return slices.ContainsFunc(out, func(c cobra.Completion) bool { return strings.SplitN(c, "\t", 2)[0] == name }) + } + if out, _ := completeProfiles(nil, nil, ""); !has(out, "all") || !has(out, "chaos") { + t.Errorf("first word: %q", out) + } + if out, _ := completeProfiles(nil, []string{"chaos"}, ""); has(out, "all") || has(out, "chaos") || !has(out, "topology") { + t.Errorf("after chaos: %q", out) + } +} + +func TestCtxImportKeepsProfilesTheFileLeavesOut(t *testing.T) { + old := Context{URL: "http://localhost:8080", Profiles: []string{"chaos"}} + if got, _ := importContext("lab", Context{URL: "http://localhost:8080"}, old, true); !reflect.DeepEqual(got.Profiles, []string{"chaos"}) { + t.Errorf("profiles = %q, want the context's own", got.Profiles) + } + if got, _ := importContext("lab", Context{URL: "http://localhost:8080", Profiles: []string{"deploy"}}, old, true); !reflect.DeepEqual(got.Profiles, []string{"deploy"}) { + t.Errorf("profiles = %q, want the file's", got.Profiles) + } + if got, _ := importContext("new", Context{URL: "http://x"}, Context{}, false); got.Profiles != nil { + t.Errorf("a new context got profiles %q", got.Profiles) + } +} + +// A refused command's help still prints on stdout, with the note on stderr; an +// enabled command's help has none. +func TestRefusedCommandHelpNote(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("KATES_PROFILES", "chaos") + prevErr, prevWarned := output.Err, profilesWarned + t.Cleanup(func() { output.Err, profilesWarned = prevErr, prevWarned }) + for _, tt := range []struct { + name string + note bool + }{{"tune", true}, {"test", false}} { + c, _, err := rootCmd.Find([]string{tt.name}) + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + c.SetOut(&stdout) + output.Err = &stderr + rootCmd.HelpFunc()(c, nil) + c.SetOut(nil) + if !strings.Contains(stdout.String(), "Available Commands:") || strings.Contains(stdout.String(), "needs the") { + t.Errorf("kates %s --help stdout:\n%s", tt.name, stdout.String()) + } + if got := strings.Contains(stripAnsi(stderr.String()), "Run once: KATES_PROFILES=all kates "+tt.name); got != tt.note { + t.Errorf("kates %s --help note on stderr = %v, want %v:\n%s", tt.name, got, tt.note, stderr.String()) + } + } +} diff --git a/cli/cmd/config.go b/cli/cmd/config.go index 08e4d477..9c5b5b61 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/bmscomp/kates/cli/output" + "github.com/charmbracelet/x/ansi" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -17,10 +18,10 @@ import ( var ctxCmd = &cobra.Command{ Use: "ctx", Aliases: []string{"context", "config"}, - Short: "Manage Kates server connection profiles (not Kubernetes contexts)", - Long: `Manage named connection profiles for the Kates API server: its URL, -API key, and preferred output format. The --context global flag selects one of -THESE profiles. + Short: "Manage Kates server contexts (not Kubernetes contexts)", + Long: `Manage named contexts for the Kates API server: its URL, API key, +preferred output format and command profiles. The --context global flag +selects one of THESE contexts. Not to be confused with Kubernetes contexts. Which CLUSTER kates deploys to comes from your kubeconfig — kates deploy detects and lets you choose it, and @@ -63,9 +64,13 @@ var ctxShowCmd = &cobra.Command{ if ctx.APIKey != "" { key = maskAPIKey(ctx.APIKey) } - rows = append(rows, []string{marker, name, ctx.URL, out, key}) + profiles := "all" + if len(ctx.Profiles) > 0 { + profiles = ansi.Truncate(strings.Join(ctx.Profiles, ", "), 24, ellipsis()) + } + rows = append(rows, []string{marker, name, ctx.URL, out, key, profiles}) } - output.Table([]string{"", "Name", "URL", "Output", "API Key"}, rows) + output.Table([]string{"", "Name", "URL", "Output", "API Key", "Profiles"}, rows) }, } @@ -117,6 +122,8 @@ var ctxSetCmd = &cobra.Command{ APIKey: ctxSetAPIKey, ProxyURL: ctxSetProxy, Insecure: ctxSetInsecure, + // Set only by kates ctx profiles set, so kept here. + Profiles: c.Contexts[name].Profiles, } // Auto-select if first context @@ -433,6 +440,9 @@ var ctxImportCmd = &cobra.Command{ // server on the next command. func importContext(name string, in, old Context, had bool) (Context, []string) { var warnings []string + if had && len(in.Profiles) == 0 { + in.Profiles = old.Profiles // a file from before profiles, or a teammate's, names none + } if isMaskedProxyURL(in.ProxyURL) { if had && maskProxyURL(old.ProxyURL) == in.ProxyURL { in.ProxyURL = old.ProxyURL // the same proxy and user diff --git a/cli/cmd/connect.go b/cli/cmd/connect.go index f3ecabc3..3a28ce64 100644 --- a/cli/cmd/connect.go +++ b/cli/cmd/connect.go @@ -27,14 +27,18 @@ var kafkaConnectCmd = &cobra.Command{ // completion included, a kubectl call with no time limit: with an // unreachable cluster in the kubeconfig, `kates version` waited minutes. // Cobra runs only the nearest PersistentPreRun, so this one runs the - // root's first, which sets up the output mode and the API client. - PersistentPreRun: func(cmd *cobra.Command, args []string) { - if rootPreRun := cmd.Root().PersistentPreRun; rootPreRun != nil { - rootPreRun(cmd, args) + // root's first, which sets up the output mode and the API client, and + // refuses a command the context's profiles do not enable. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if rootPreRun := cmd.Root().PersistentPreRunE; rootPreRun != nil { + if err := rootPreRun(cmd, args); err != nil { + return err + } } if !cmd.Flags().Changed("namespace") { connectNamespace = detectConnectNamespace(cmd.Context()) } + return nil }, } diff --git a/cli/cmd/context_url_test.go b/cli/cmd/context_url_test.go index 6b01c02c..a4f40de6 100644 --- a/cli/cmd/context_url_test.go +++ b/cli/cmd/context_url_test.go @@ -105,7 +105,9 @@ func runWithPreRun(t *testing.T, labURL, flagURL string, env map[string]string) var stderr bytes.Buffer output.Err = &stderr - rootCmd.PersistentPreRun(rootCmd, nil) + if err := rootCmd.PersistentPreRunE(rootCmd, nil); err != nil { + t.Fatal(err) + } commandStdout(t, func() error { return runCommandLine(t, "test", "list") }) return stderr.String() } diff --git a/cli/cmd/ctx_export_test.go b/cli/cmd/ctx_export_test.go index 17a3b12f..da9ea5eb 100644 --- a/cli/cmd/ctx_export_test.go +++ b/cli/cmd/ctx_export_test.go @@ -138,7 +138,7 @@ func TestCtxImport_RoundTrip(t *testing.T) { _, _, _ = commandOutput(t, func() error { runCtxCommandLine(t, "ctx", "import", "--file", revealedFile); return nil }) got := loadConfig().Contexts for name, want := range exportTestConfig().Contexts { - if got[name] != want { + if !reflect.DeepEqual(got[name], want) { t.Errorf("%s = %+v, want %+v", name, got[name], want) } } diff --git a/cli/cmd/data/doc_entries.yaml b/cli/cmd/data/doc_entries.yaml index 8ef03706..98dd94d5 100644 --- a/cli/cmd/data/doc_entries.yaml +++ b/cli/cmd/data/doc_entries.yaml @@ -1361,6 +1361,42 @@ - ctx show - ctx use +- name: ctx profiles + category: Config + synopsis: "kates ctx profiles [profile...|all]" + short: "Show the command profiles a context enables" + description: | + Shows which command profiles the current context enables, or the one + --context names, and the commands each profile enables. A context with + profiles enables only their commands, plus the ones every profile needs; + kates lists only those and refuses the others. A context with no + profiles enables every command. KATES_PROFILES overrides the context's + profiles; KATES_PROFILES=all enables every command. Name profiles, or + all, to list their commands with what each one does. + examples: + - "kates ctx profiles" + - "kates ctx profiles chaos topology" + - "kates ctx profiles --context lab -o json" + see_also: + - ctx profiles set + - ctx show + +- name: ctx profiles set + category: Config + synopsis: "kates ctx profiles set [profile...|all]" + short: "Choose the command profiles a context enables" + description: | + Replaces the command profiles of the current context, or of the one + --context names: performance, integrity, chaos, topology, security, + deploy. all enables every command. With no profile named, in a + terminal, it asks which ones to enable. + examples: + - "kates ctx profiles set chaos topology" + - "kates ctx profiles set performance --context lab" + - "kates ctx profiles set all" + see_also: + - ctx profiles + - name: dashboard category: Observability synopsis: "kates dashboard [flags]" @@ -1611,7 +1647,8 @@ description: | Reads context definitions from a YAML file (or stdin) and merges them into ~/.kates.yaml. Existing contexts with the - same name are overwritten. A masked API key is never stored: + same name are overwritten, but keep their command profiles + when the file names none. A masked API key is never stored: an existing context keeps its key only while the file leaves its URL, proxy and insecure setting unchanged. flags: diff --git a/cli/cmd/main_test.go b/cli/cmd/main_test.go index dde2c6a8..e482b99b 100644 --- a/cli/cmd/main_test.go +++ b/cli/cmd/main_test.go @@ -27,6 +27,11 @@ func TestMain(m *testing.M) { os.Exit(1) } os.Setenv("HOME", home) + // A developer's own KATES_PROFILES would refuse the commands tests run. As + // runKates' kates, the binary keeps the one the test passes. + if os.Getenv("KATES_TEST_RUN_AS_CLI") != "1" { + os.Unsetenv("KATES_PROFILES") + } code := m.Run() os.RemoveAll(home) os.Exit(code) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index e005af7e..aa7a8cad 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -43,6 +43,9 @@ type Context struct { // with kates ctx import, one changed in the file by hand, and every key in // a config written before this field existed. KeySource string `yaml:"key-source,omitempty"` + // Profiles are the command profiles this context enables (see + // commandProfiles). None enables every command. + Profiles []string `yaml:"profiles,omitempty"` } type Config struct { @@ -61,12 +64,17 @@ func configPath() string { func loadConfig() Config { cfg, err := readConfig() - if err != nil { + if err != nil && !configWarned { fmt.Fprintf(os.Stderr, "Warning: could not load config %s: %v\n", configPath(), err) + configWarned = true } return cfg } +// configWarned keeps loadConfig's warning to one a process: the help function +// and the pre-run both load the config. +var configWarned bool + // readConfig returns the config on disk, or the built-in default context when // there is no file. It reports a file it cannot read or parse, which // loadConfig only warns about but updateConfig refuses to write over: saving @@ -277,6 +285,16 @@ func followSymlinks(path string) (string, error) { // about it can say where the name came from. var contextFromEnv bool +// readContextEnv takes the context name from KATES_CONTEXT when --context +// gave none. +func readContextEnv() { + if contextFlag == "" { + if envCtx := os.Getenv("KATES_CONTEXT"); envCtx != "" { + contextFlag, contextFromEnv = envCtx, true + } + } +} + // resolveContext returns the context commands talk to, by name: the one // --context or KATES_CONTEXT names, or else the config's current context. // @@ -330,10 +348,9 @@ Health & Status: health System health, Kafka connectivity, engine status status Quick one-line system status doctor Pre-flight cluster readiness checklist - detect Deep cluster compatibility report for Kafka - auto Auto-detect cluster config and deploy Kafka version CLI, API, and runtime version info cluster Kafka cluster metadata and topic listing + diff Compare Kafka cluster state between two contexts test list List test runs with optional filters test get Show details of a specific test run @@ -359,7 +376,6 @@ Tuning: Analysis: report View reports, export CSV/JUnit/Markdown/HTML trend Historical performance trends with sparkline charts - diff Side-by-side comparison of two test run reports explain Plain-English test narrative with verdict replay Re-run a previous test with the same parameters scenario-diff Compare scenario YAML against a test run @@ -387,9 +403,13 @@ Toolbox: tldr Quick command reference cheatsheet docs Man-style documentation for all commands theme Manage CLI color themes + mcp Serve Kates to AI agents over MCP + upgrade Build and install a new version of the CLI Configuration: - ctx Manage server contexts (like kubectl contexts) + ctx Manage Kates server contexts (not kubectl contexts) + ctx profiles Show or choose the command profiles a context enables + ports Port-forward the Kates services to localhost completion Shell auto-completion (bash, zsh, fish, powershell) Disruption & Chaos: @@ -399,20 +419,31 @@ Disruption & Chaos: schedule Automated recurring test schedules Security: - security audit Full security posture scan with A-F grading - security tls TLS protocol, cipher, and certificate inspection - security auth-test ACL authorization probing per user - security pentest Adversarial penetration tests - security compliance CIS Kafka, SOC2, PCI-DSS compliance report - security baseline Save security snapshot for drift detection - security drift Compare posture against saved baseline - security gate CI/CD gate — exit non-zero if below grade + security audit Full security posture scan with A-F grading + security tls TLS protocol, cipher, and certificate inspection + security auth-test ACL authorization probing per user + security pentest Adversarial penetration tests + security compliance CIS Kafka, SOC2, PCI-DSS compliance report + security baseline Save security snapshot for drift detection + security drift Compare posture against saved baseline + security gate CI/CD gate — exit non-zero if below grade Policy Engine (Kyverno): - kyverno status Show all ClusterPolicies with mode and rule counts - kyverno violations Pretty-print policy violations by namespace - kyverno enforce Switch a policy from Audit to Enforce mode - kyverno audit Switch a policy from Enforce to Audit mode + kyverno status Show all ClusterPolicies with mode and rule counts + kyverno violations Pretty-print policy violations by namespace + kyverno enforce Switch a policy from Audit to Enforce mode + kyverno audit Switch a policy from Enforce to Audit mode + +Deployment: + deploy Deploy the Kates stack (Kafka, Kates, chaos, monitoring) + deploy status Health of the deployed components + detect Deep cluster compatibility report for Kafka + auto Auto-detect cluster config and deploy Kafka + operators Strimzi operators on this cluster + versions Strimzi and Kafka versions this cluster can run + migrate Cross-version Kafka migration with MirrorMaker 2 + operator Run the Kates Environment Operator + clean Remove every Kates-managed release and namespace Flags: -o, --output Output format: table or json @@ -426,6 +457,7 @@ Environment Variables: KATES_OUTPUT Override output format (lower priority than -o) KATES_CONTEXT Override context (lower priority than --context) KATES_API_KEY API key for authentication (lower priority than --api-key) + KATES_PROFILES Command profiles for this call; all enables every command Examples: $ kates health @@ -445,14 +477,24 @@ var rootCmd = &cobra.Command{ Use: "kates", Short: "Kates — Kafka Advanced Testing & Engineering Suite CLI", Long: helpTemplate, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - if contextFlag == "" { - if envCtx := os.Getenv("KATES_CONTEXT"); envCtx != "" { - contextFlag, contextFromEnv = envCtx, true + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + complete := cmd.Name() == cobra.ShellCompRequestCmd || cmd.Name() == cobra.ShellCompNoDescRequestCmd + if complete { + // Cobra parses no flags for __complete: read the --context of the + // command line being completed, whose last word is still typed. + words := args[:max(0, len(args)-1)] + for i, w := range words { + if v, ok := strings.CutPrefix(w, "--context="); ok { + contextFlag = v + } else if w == "--context" && i+1 < len(words) { + contextFlag = words[i+1] + } } } + readContextEnv() cfg := loadConfig() ctxName, ctx, ctxErr := resolveContext(cfg) + profiles, profilesFrom := activeProfiles(ctxName, ctx) urlOverridden := apiURL != "" if apiURL == "" { if envURL := os.Getenv("KATES_URL"); envURL != "" { @@ -462,6 +504,7 @@ var rootCmd = &cobra.Command{ } } activeContext = ctxName + activeProfileNames, activeProfilesFrom = profiles, profilesFrom apiURLOverridden = urlOverridden // The context's API key, proxy and TLS setting belong to its server. // They used to go with whatever URL --url or KATES_URL gave, so the @@ -495,6 +538,12 @@ var rootCmd = &cobra.Command{ output.SetPlain(plainOutput) + if complete { + hideDisabledCommands(cmd.Root(), profiles) + } else if err := profileRefusal(cmd, ctxName, profiles, profilesFrom); err != nil { + return err + } + if apiKeyFlag == "" { if envKey := os.Getenv("KATES_API_KEY"); envKey != "" { apiKeyFlag = envKey @@ -514,6 +563,7 @@ var rootCmd = &cobra.Command{ // still run; every API call reports the missing context. apiClient = client.Failing(ctxErr) } + return nil }, SilenceUsage: true, SilenceErrors: true, @@ -609,11 +659,26 @@ func init() { defaultHelp := rootCmd.HelpFunc() rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + // Cobra prints help before PersistentPreRunE runs, so the profiles are + // read here too. + readContextEnv() + ctxName, ctx, _ := resolveContext(loadConfig()) + profiles, from := activeProfiles(ctxName, ctx) + if v := os.Getenv("KATES_PLAIN"); plainOutput || v == "true" || v == "1" { + output.SetPlain(true) + } if cmd != rootCmd { defaultHelp(cmd, args) + // The help of a command the profiles refuse says so, and how to run it. + if top := topLevel(cmd); !commandEnabled(top.Name(), profiles) { + fmt.Fprintln(output.Err) + fmt.Fprintln(output.Err, output.WarningStyle.Render( + fmt.Sprintf(" ! kates %s needs the %s profile", top.Name(), orList(commandProfiles[top.Name()])))) + profileFix(cmd, ctxName, profiles, from) + } return } - fmt.Println(cmd.Long) + fmt.Println(profileHelp(cmd.Long, ctxName, profiles, from)) fmt.Println() fmt.Println("Usage:") fmt.Printf(" %s [command]\n", cmd.CommandPath()) diff --git a/cli/cmd/root_test.go b/cli/cmd/root_test.go index 050308b1..8108f398 100644 --- a/cli/cmd/root_test.go +++ b/cli/cmd/root_test.go @@ -492,7 +492,7 @@ func TestCtxCommands(t *testing.T) { case tt.wantCurrent == "" && cfg.Contexts[cfg.CurrentContext].URL == "": t.Errorf("current context = %q, want one that exists", cfg.CurrentContext) } - if got := cfg.Contexts["mcp"]; got != start.Contexts["mcp"] { + if got := cfg.Contexts["mcp"]; !reflect.DeepEqual(got, start.Contexts["mcp"]) { t.Errorf("mcp context = %+v, want it unchanged", got) } }) diff --git a/cli/cmd/tldr.go b/cli/cmd/tldr.go index a4c4db89..e99c2e0f 100644 --- a/cli/cmd/tldr.go +++ b/cli/cmd/tldr.go @@ -26,6 +26,7 @@ var tldrGroups = []tldrGroup{ {"Set up a context", "kates ctx set local --url http://localhost:30083"}, {"Switch context", "kates ctx use local"}, {"Check system health", "kates health"}, + {"Enable only chaos commands", "kates ctx profiles set chaos"}, {"Initialize a project", "kates init --name my-project"}, }, }, @@ -44,7 +45,7 @@ var tldrGroups = []tldrGroup{ Entries: []tldrEntry{ {"View report", "kates report show "}, {"Export as HTML", "kates report export --format html"}, - {"Compare two runs", "kates diff "}, + {"Compare two runs", "kates report diff "}, {"Performance trends", "kates trend --type LOAD --days 14"}, {"Get recommendations", "kates advisor "}, }, @@ -92,8 +93,7 @@ var ( tldrCatStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.Secondary). - MarginTop(1) + Foreground(theme.Secondary) ) var tldrCmd = &cobra.Command{ @@ -110,26 +110,11 @@ organized by workflow. Like 'tldr' but for Kates.`, filter = strings.ToLower(args[0]) } - fmt.Println() - fmt.Println(" " + tldrTitleStyle.Render("Kates Quick Reference")) - fmt.Println() - + // The entries the filter matches and the profiles enable. + var shown []tldrGroup + hidden := false for _, g := range tldrGroups { - if filter != "" && !strings.Contains(strings.ToLower(g.Category), filter) { - match := false - for _, e := range g.Entries { - if strings.Contains(strings.ToLower(e.Command), filter) || - strings.Contains(strings.ToLower(e.Desc), filter) { - match = true - break - } - } - if !match { - continue - } - } - - fmt.Println(" " + tldrCatStyle.Render(g.Category)) + var entries []tldrEntry for _, e := range g.Entries { if filter != "" && !strings.Contains(strings.ToLower(e.Desc), filter) && @@ -137,6 +122,29 @@ organized by workflow. Like 'tldr' but for Kates.`, !strings.Contains(strings.ToLower(g.Category), filter) { continue } + if !commandEnabled(strings.Fields(strings.TrimPrefix(e.Command, "kates "))[0], activeProfileNames) { + hidden = true + continue + } + entries = append(entries, e) + } + if len(entries) > 0 { + shown = append(shown, tldrGroup{Category: g.Category, Entries: entries}) + } + } + + fmt.Println() + fmt.Println(" " + tldrTitleStyle.Render("Kates Quick Reference")) + if hidden { + fmt.Println(" " + tldrDescStyle.Render(fmt.Sprintf("Profiles: %s (%s)", strings.Join(activeProfileNames, ", "), activeProfilesFrom))) + } + if hidden && len(shown) == 0 { + fmt.Println(" " + tldrDescStyle.Render(strings.TrimSpace("Every entry: KATES_PROFILES=all kates tldr "+filter))) + } + fmt.Println() + for _, g := range shown { + fmt.Println(" " + tldrCatStyle.Render(g.Category)) + for _, e := range g.Entries { fmt.Printf(" %s\n", tldrDescStyle.Render(e.Desc+":")) fmt.Printf(" %s\n", tldrCmdStyle.Render("$ "+e.Command)) } diff --git a/docs/book/10-cli-reference.md b/docs/book/10-cli-reference.md index 756d053b..c3063268 100644 --- a/docs/book/10-cli-reference.md +++ b/docs/book/10-cli-reference.md @@ -198,6 +198,45 @@ kates ctx import --file my-contexts.yaml A command that changes the file first takes a lock on `~/.kates.yaml.lock`, which stays beside it, and then writes a complete new file in place of the old one. Two commands that change contexts at once therefore do not undo each other's changes, and a save that fails leaves the old file as it was. +### Command Profiles + +A context can enable only the commands its user works with. Each profile enables a set of top-level commands, and a subcommand follows its top-level command: + +| Profile | Enables | +|:--------|:--------| +| `performance` | `advisor`, `badge`, `benchmark`, `cost`, `dashboard`, `explain`, `flow`, `gate`, `lab`, `profile`, `replay`, `report`, `scenario-diff`, `schedule`, `test`, `top`, `trend`, `tune`, `watch`, `webhook` | +| `integrity` | `dashboard`, `explain`, `kafka`, `migrate`, `replay`, `report`, `scenario-diff`, `schedule`, `test`, `top`, `watch`, `webhook` | +| `chaos` | `chaos`, `cluster`, `disruption`, `flow`, `report`, `resilience`, `test`, `watch` | +| `topology` | `cluster`, `diff`, `doctor`, `kafka`, `snapshot` | +| `security` | `kyverno`, `security` | +| `deploy` | `auto`, `clean`, `deploy`, `detect`, `doctor`, `kyverno`, `migrate`, `operator`, `operators`, `versions` | + +Every profile also enables `audit`, `changelog`, `completion`, `ctx`, `docs`, `health`, `help`, `init`, `mcp`, `plugin`, `ports`, `status`, `theme`, `tldr`, `upgrade`, `version` and plugins. A context with no profiles enables every command, which is how every context starts. + +```bash +kates ctx profiles # this context's profiles and their commands +kates ctx profiles chaos # each chaos command and what it does +kates ctx profiles set chaos topology # replace them +kates ctx profiles set performance --context lab +kates ctx profiles set # in a terminal, pick them from a list +kates ctx profiles set all # every command again +``` + +`kates ctx profiles` marks the context's profiles with `●`, lays each profile's commands out in columns, and dims the commands the profiles do not enable. Name one or more profiles, or `all`, to list their commands one a line with what each does and which other profiles enable it; `-o json` gives the same as data. + +`kates` names the context's profiles above Quick Start and lists only the commands they enable, and shell completion offers only those. A command they do not enable is refused before it calls the Kates API, and exits 1: + +```text + ✖ kates tune needs the performance profile + Enabled: chaos, topology (context "lab") + Enable it: kates ctx profiles set performance chaos topology + Run once: KATES_PROFILES=all kates tune types … +``` + +`KATES_PROFILES` replaces the context's profiles for one call or one shell: a comma- or space-separated list, or `all` for every command. An empty `KATES_PROFILES` enables every command too. A name kates does not know, in the file or in the variable, is warned about on stderr and enables nothing, so a context that names only unknown ones runs only the commands every profile enables. `kates ctx set` keeps the profiles of a context it updates, `kates ctx export` carries them, and `kates ctx import` keeps a context's profiles when the file names none. `kates help ` and `kates --help` show the help of any command, with a note on stderr when the profiles do not enable it. + +Profiles shorten what `kates` lists and runs; they are not access control. `KATES_PROFILES=all` lifts them, and `kates mcp` ignores them. What an API key may do is set by its scopes, in [Scopes and Named Keys](11-api-reference.md#scopes-and-named-keys). + ### Config File Format ```yaml @@ -210,6 +249,9 @@ contexts: url: http://localhost:30083 output: table api-key: + profiles: + - chaos + - topology staging: url: https://kates-staging.example.com output: table @@ -241,7 +283,7 @@ Find your question in the table below, then follow its link to the family's comm | Family | Commands | The question it answers | What it talks to | |:-----|:--------|:--------|:---------| -| [Context Management](#context-management) | `ctx set`, `use`, `show`, `export`, `import` | Which Kates API do your commands call, and with which key? | Local files: `~/.kates.yaml` | +| [Context Management](#context-management) | `ctx set`, `use`, `show`, `export`, `import`, `profiles` | Which Kates API do your commands call, and with which key? | Local files: `~/.kates.yaml` | | [Health, Status & Diagnostics](#health-status--diagnostics) | `health`, `status`, `version`, `doctor` | Is the Kates API up, does it reach Kafka, and is the cluster ready to test? | Kates API; `doctor` also asks `kubectl` about Kyverno, and `doctor dns` and `doctor network` work through `kubectl` alone | | [Cluster Commands](#cluster-commands) | `cluster info`, `check`, `topology`, `alerts`, `watch`, `topics`, `groups`, `broker configs` | What does the Kafka cluster look like, and is it healthy? | Kates API | | [Test Commands](#test-commands) | `test list`, `create`, `get`, `delete`, `watch`, `apply`, `scaffold` | How do you start a performance test, follow it and find it again? | Kates API; `test scaffold` uses only templates built into the CLI | diff --git a/eval/mcp/TASKS.md b/eval/mcp/TASKS.md index 9bb6bdd9..2e245900 100644 --- a/eval/mcp/TASKS.md +++ b/eval/mcp/TASKS.md @@ -105,7 +105,7 @@ Tools and commands the agent may not use in that task, because they would leak t | Kind | Fields | What it does | | --- | --- | --- | -| `kates` | `args`, optional `capture`, `expect`, `repeat`, `allow_fail`, `timeout_s` | Runs `kates --context ` with `KATES_URL`, `KATES_API_KEY`, `KATES_CONTEXT` and `KATES_OUTPUT` removed from the environment. A step that captures or expects must pass `-o json`. A step may not pass `--context`, `--url` or `--api-key` | +| `kates` | `args`, optional `capture`, `expect`, `repeat`, `allow_fail`, `timeout_s` | Runs `kates --context ` with `KATES_URL`, `KATES_API_KEY`, `KATES_CONTEXT` and `KATES_OUTPUT` removed from the environment and `KATES_PROFILES` set to `all`. A step that captures or expects must pass `-o json`. A step may not pass `--context`, `--url` or `--api-key` | | `api` | `method`, `path`, `why`, optional `body`, `capture`, `expect`, `timeout_s` | A GET, or a POST to `/api/disruptions/templates/…` only (`taskfile.SETUP_API_POSTS`), with the human key (`run.py` reads it from the human context; `setup.py` on its own from `KATES_EVAL_API_KEY`). `why` says what no kates command does | | `wait` | `seconds`, optional `since` | Sleeps; with `since` (an epoch capture), only what is left of `seconds` after it | | `clock` | `capture` | Captures the time as `rfc3339`, `rfc3339_minute` (floored), `epoch` or `hhmm_utc` | diff --git a/eval/mcp/setup.py b/eval/mcp/setup.py index d826cef6..f1f0ea91 100644 --- a/eval/mcp/setup.py +++ b/eval/mcp/setup.py @@ -76,6 +76,7 @@ def post(self, path: str, body: Any, timeout: float) -> Any: def run_kates_subprocess(argv: list[str], timeout: float) -> tuple[int, str, str]: env = {k: v for k, v in os.environ.items() if k not in STRIPPED_ENV} + env["KATES_PROFILES"] = "all" # setup is the human doing everything, whatever the context's profiles try: p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env=env, stdin=subprocess.DEVNULL) except subprocess.TimeoutExpired: diff --git a/eval/mcp/test_setup.py b/eval/mcp/test_setup.py index 94719029..057f92b5 100644 --- a/eval/mcp/test_setup.py +++ b/eval/mcp/test_setup.py @@ -287,14 +287,14 @@ class Subprocess(unittest.TestCase): def test_overrides_of_the_context_are_stripped(self) -> None: probe = [sys.executable, "-c", "import json, os; print(json.dumps({k: os.environ.get(k) for k in " - "('KATES_URL', 'KATES_API_KEY', 'KATES_CONTEXT', 'KATES_OUTPUT', 'KEEP_ME')}))"] + "('KATES_URL', 'KATES_API_KEY', 'KATES_CONTEXT', 'KATES_OUTPUT', 'KATES_PROFILES', 'KEEP_ME')}))"] env = {"KATES_URL": "http://elsewhere", "KATES_API_KEY": "leak", "KATES_CONTEXT": "prod", - "KATES_OUTPUT": "table", "KEEP_ME": "1"} + "KATES_OUTPUT": "table", "KATES_PROFILES": "chaos", "KEEP_ME": "1"} with mock.patch.dict(os.environ, env): code, out, _ = setup.run_kates_subprocess(probe, 30) self.assertEqual(code, 0) self.assertEqual(json.loads(out), {"KATES_URL": None, "KATES_API_KEY": None, "KATES_CONTEXT": None, - "KATES_OUTPUT": None, "KEEP_ME": "1"}) + "KATES_OUTPUT": None, "KATES_PROFILES": "all", "KEEP_ME": "1"}) def test_a_missing_binary(self) -> None: code, _, err = setup.run_kates_subprocess(["/nonexistent/kates", "version"], 5)