From 85e85156514400e25cf08ed292ac5ce54114b124 Mon Sep 17 00:00:00 2001 From: Said Boudjelda Date: Sat, 17 Oct 2026 23:29:00 +0200 Subject: [PATCH 1/2] feat(cli): a context enables only the commands of the profiles it names, and kates lists, completes and refuses by them kates listed every command to everyone, in a hand-written help that left out the deploy family. A context can now name the command profiles its user works in: performance, integrity, chaos, topology, security or deploy. commandProfiles in command_profiles.go maps each top-level command to the profiles that enable it. A subcommand follows its parent, and the commands every profile needs (ctx, health, help, ports, mcp and a few more) are always enabled, as are plugins. A context with no profiles, which is every context today, enables every command. With profiles set, kates names them above Quick Start and lists only their commands' lines, shell completion offers only those commands, and any other command is refused before it calls the Kates API. The refusal exits 1 and gives the two ways out: the kates ctx profiles set line that enables the command and keeps the others, and KATES_PROFILES=all for one call. KATES_PROFILES overrides the context's profiles for a call or a shell. The check runs in the root PersistentPreRunE, which replaces PersistentPreRun; kafka connect's own hook chains it. Completion reads --context from the line being completed, since cobra parses no flags for __complete. kates mcp is always enabled and its process never hides a command, so the CLI docs resource agents read keeps every one. kates ctx profiles lays each profile's commands out in columns that line up across profiles, marks the enabled profiles, and dims the commands they refuse. Named profiles, or all, list each command with what it does and the other profiles that enable it; -o json gives the same as data. kates ctx profiles set takes profiles or all; with none named, in a terminal, it opens a huh checklist, the ticked profiles first because huh scrolls the list to the first ticked option. kates ctx set keeps a context's profiles, and kates ctx show has a Profiles column. The root help gains a Deployment section with the deploy family #90 left out, since a deploy profile has nothing to list without it, and lines for ctx profiles, ports, mcp and upgrade. diff is described as the cluster comparison it is, and the Security and Kyverno lines line up. Tests cover the table against the registered commands, the refusal and its messages, the help filter and header, the grid's alignment, the details view and its JSON, kates ctx profiles set, and kates ctx set keeping the profiles. Run as kates through the test binary, they cover a refusal that reaches no server, and completion under KATES_PROFILES and under --context. TestMain unsets a developer's own KATES_PROFILES. Book 10 gets a Command Profiles section, doc_entries.yaml two entries and tldr one. --- cli/cmd/command_profiles.go | 583 +++++++++++++++++++++++++++++++ cli/cmd/command_profiles_test.go | 415 ++++++++++++++++++++++ cli/cmd/config.go | 11 +- cli/cmd/connect.go | 12 +- cli/cmd/context_url_test.go | 4 +- cli/cmd/ctx_export_test.go | 2 +- cli/cmd/data/doc_entries.yaml | 36 ++ cli/cmd/main_test.go | 5 + cli/cmd/root.go | 89 +++-- cli/cmd/root_test.go | 2 +- cli/cmd/tldr.go | 1 + docs/book/10-cli-reference.md | 42 ++- 12 files changed, 1172 insertions(+), 30 deletions(-) create mode 100644 cli/cmd/command_profiles.go create mode 100644 cli/cmd/command_profiles_test.go diff --git a/cli/cmd/command_profiles.go b/cli/cmd/command_profiles.go new file mode 100644 index 00000000..63a8fed3 --- /dev/null +++ b/cli/cmd/command_profiles.go @@ -0,0 +1,583 @@ +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 dropped. 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, " ! Ignoring profile %q from %s: kates knows %s\n", n, from, strings.Join(knownProfiles, ", ")) + } + } + profilesWarned = true + return inProfileOrder(names), from +} + +// 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 + } + needs := commandProfiles[top.Name()] + err := cmdErr(fmt.Sprintf("kates %s needs the %s profile", top.Name(), orList(needs))) + output.Hint(fmt.Sprintf(" Enabled: %s (%s)", strings.Join(profiles, ", "), from)) + if from == "KATES_PROFILES" { + output.Hint(fmt.Sprintf(" Enable it: add %s to KATES_PROFILES", needs[0])) + } else { + output.Hint(fmt.Sprintf(" Enable it: kates ctx profiles set %s%s", + strings.Join(inProfileOrder(append(slices.Clone(profiles), needs[0])), " "), contextArg(ctxName))) + } + output.Hint(fmt.Sprintf(" Run once: KATES_PROFILES=all %s %s", cmd.CommandPath(), ellipsis())) + return err +} + +// 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. +func profileHelp(long, ctxName string, profiles []string, from string) string { + list := "none, every command is enabled" + if len(profiles) > 0 { + list = strings.Join(profiles, ", ") + } + var header string + switch { + case from == "KATES_PROFILES": + header = fmt.Sprintf(" Profiles: %s (KATES_PROFILES)\n Change them: edit or unset KATES_PROFILES\n\n", list) + case len(profiles) == 0: + header = fmt.Sprintf(" Context: %s · Profiles: %s\n Narrow the list: kates ctx profiles set ...%s\n\n", + ctxName, list, contextArg(ctxName)) + default: + header = fmt.Sprintf(" Context: %s · Profiles: %s\n Change them: kates ctx profiles set%s\n Every command: KATES_PROFILES=all kates\n\n", + ctxName, list, contextArg(ctxName)) + } + 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") + output.KeyValue("Context", activeContext) + if len(active) == 0 { + output.KeyValue("Profiles", "none, every command is enabled") + } else { + output.KeyValue("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), ", "))) + } + } + return append(out, cobra.CompletionWithDesc("all", "every command")), 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..d5e63d8d --- /dev/null +++ b/cli/cmd/command_profiles_test.go @@ -0,0 +1,415 @@ +package cmd + +import ( + "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 dropped with a warning", stored: []string{"chaso", "chaos"}, want: []string{"chaos"}, wantErr: `Ignoring profile "chaso"`}, + {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"}}, + {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() + 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) + } + } + }) + } +} + +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 !strings.Contains(all, "Context: lab · Profiles: none, every command is enabled") { + t.Errorf("help without profiles does not say so:\n%s", all) + } + for _, line := range strings.Split(helpTemplate, "\n") { + if !strings.Contains(all, line) { + t.Errorf("help without profiles lost %q", line) + } + } + + chaos := profileHelp(helpTemplate, "lab", []string{"chaos"}, `context "lab"`) + for _, want := range []string{ + "Context: lab · Profiles: chaos", "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=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) + } + } + } + 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) + } +} diff --git a/cli/cmd/config.go b/cli/cmd/config.go index 08e4d477..f6ff61b3 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" ) @@ -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 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..49b7ad85 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]" 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..bf9a8cfb 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 { @@ -277,6 +280,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 +343,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 +371,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 +398,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 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 +414,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 +452,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 +472,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 != "" { @@ -495,6 +532,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 +557,7 @@ var rootCmd = &cobra.Command{ // still run; every API call reports the missing context. apiClient = client.Failing(ctxErr) } + return nil }, SilenceUsage: true, SilenceErrors: true, @@ -613,7 +657,12 @@ func init() { defaultHelp(cmd, args) return } - fmt.Println(cmd.Long) + // Cobra prints help before PersistentPreRunE runs, so the profiles are + // read here too. + readContextEnv() + ctxName, ctx, _ := resolveContext(loadConfig()) + profiles, from := activeProfiles(ctxName, ctx) + 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..152f6b36 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"}, }, }, diff --git a/docs/book/10-cli-reference.md b/docs/book/10-cli-reference.md index 756d053b..fae461cc 100644 --- a/docs/book/10-cli-reference.md +++ b/docs/book/10-cli-reference.md @@ -198,6 +198,43 @@ 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 ignored with a warning on stderr. `kates ctx set` keeps the profiles of a context it updates, and `kates ctx export` and `kates ctx import` carry them. `kates help ` and `kates --help` show the help of any command. + ### Config File Format ```yaml @@ -210,6 +247,9 @@ contexts: url: http://localhost:30083 output: table api-key: + profiles: + - chaos + - topology staging: url: https://kates-staging.example.com output: table @@ -241,7 +281,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 | From fbace69bea2701d11b864552a9fa1f454954d88e Mon Sep 17 00:00:00 2001 From: Said Boudjelda Date: Sun, 18 Oct 2026 09:57:44 +0200 Subject: [PATCH 2/2] fix(cli): a profile refusal leaves stdout to the command, an unknown profile enables nothing, and kates help and tldr follow the profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refusal printed its three fix lines on stdout, so `kates … -o json | jq` read text. They now go to stderr with the error, through profileFix. Its Run once line kept no --context, so pasting it ran the command against the current context; it now keeps it, like the Enable it line. The help of a command the profiles refuse (kates tune --help, kates help tune, bare kates tune) still prints, then the same note and fix lines on stderr, and exits 0. The help path honours --plain and KATES_PLAIN, and loadConfig warns about a broken ~/.kates.yaml once a process, since the help and the pre-run both load it. A context whose profiles were all unknown names (a typo, a teammate's newer name) enabled every command, while a mix of known and unknown names narrowed to the known ones. Unknown names now enable nothing, alone or mixed, and stay listed after the known ones, so the typo shows in the header, the refusal and kates ctx profiles. The warning says Unknown profile. The kates header is left out when no profiles are set, since nothing is filtered. Its lines put their values at column 17, where the help's descriptions start, and name the profiles' source as the refusal does. kates ctx profiles puts its Context and Profiles values at column 21, where the grid's commands start. kates tldr lists only the entries the profiles enable, names the profiles when it hides some, and says how to see every entry when a filter hides them all. Its "Compare two runs" line named kates diff, which compares two clusters; it is kates report diff. Its category titles are indented under the title again. Completion offers all only as the first profile. kates ctx calls contexts contexts, not connection profiles, and book 10 says profiles are not access control and points at the API key scopes. kates ctx import keeps a context's profiles when the file names none, as kates ctx set does. The MCP eval's human-side setup runs with KATES_PROFILES=all, since it needs commands of several profiles; TASKS.md and its test say so. --- cli/cmd/command_profiles.go | 89 ++++++++++++++++++--------- cli/cmd/command_profiles_test.go | 102 +++++++++++++++++++++++++++---- cli/cmd/config.go | 11 ++-- cli/cmd/data/doc_entries.yaml | 3 +- cli/cmd/root.go | 28 +++++++-- cli/cmd/tldr.go | 51 +++++++++------- docs/book/10-cli-reference.md | 4 +- eval/mcp/TASKS.md | 2 +- eval/mcp/setup.py | 1 + eval/mcp/test_setup.py | 6 +- 10 files changed, 217 insertions(+), 80 deletions(-) diff --git a/cli/cmd/command_profiles.go b/cli/cmd/command_profiles.go index 63a8fed3..09ada5f4 100644 --- a/cli/cmd/command_profiles.go +++ b/cli/cmd/command_profiles.go @@ -83,7 +83,8 @@ var commandProfiles = map[string][]string{ // 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 dropped. None, or "all" among them, enables every command. +// 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 { @@ -94,13 +95,29 @@ func activeProfiles(ctxName string, ctx Context) (profiles []string, from string } for _, n := range names { if !slices.Contains(knownProfiles, n) && !profilesWarned { - fmt.Fprintf(output.Err, " ! Ignoring profile %q from %s: kates knows %s\n", n, from, strings.Join(knownProfiles, ", ")) + fmt.Fprintf(output.Err, " ! Unknown profile %q from %s: kates knows %s\n", n, from, strings.Join(knownProfiles, ", ")) } } profilesWarned = true - return inProfileOrder(names), from + // 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 @@ -144,17 +161,26 @@ func profileRefusal(cmd *cobra.Command, ctxName string, profiles []string, from if commandEnabled(top.Name(), profiles) { return nil } - needs := commandProfiles[top.Name()] - err := cmdErr(fmt.Sprintf("kates %s needs the %s profile", top.Name(), orList(needs))) - output.Hint(fmt.Sprintf(" Enabled: %s (%s)", strings.Join(profiles, ", "), from)) + 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" { - output.Hint(fmt.Sprintf(" Enable it: add %s to KATES_PROFILES", needs[0])) - } else { - output.Hint(fmt.Sprintf(" Enable it: kates ctx profiles set %s%s", - strings.Join(inProfileOrder(append(slices.Clone(profiles), needs[0])), " "), contextArg(ctxName))) + 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)) } - output.Hint(fmt.Sprintf(" Run once: KATES_PROFILES=all %s %s", cmd.CommandPath(), ellipsis())) - return err } // contextArg is " --context " when --context chose the context, so a @@ -186,23 +212,19 @@ func hideDisabledCommands(root *cobra.Command, profiles []string) { // 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. +// 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 { - list := "none, every command is enabled" - if len(profiles) > 0 { - list = strings.Join(profiles, ", ") + if len(profiles) == 0 { + return long } - var header string - switch { - case from == "KATES_PROFILES": - header = fmt.Sprintf(" Profiles: %s (KATES_PROFILES)\n Change them: edit or unset KATES_PROFILES\n\n", list) - case len(profiles) == 0: - header = fmt.Sprintf(" Context: %s · Profiles: %s\n Narrow the list: kates ctx profiles set ...%s\n\n", - ctxName, list, contextArg(ctxName)) - default: - header = fmt.Sprintf(" Context: %s · Profiles: %s\n Change them: kates ctx profiles set%s\n Every command: KATES_PROFILES=all kates\n\n", - ctxName, list, contextArg(ctxName)) + 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") @@ -303,11 +325,15 @@ profiles for one call; KATES_PROFILES=all enables every command.`, } output.Header("Command Profiles") - output.KeyValue("Context", activeContext) + // 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 { - output.KeyValue("Profiles", "none, every command is enabled") + key("Profiles", "none, every command is enabled") } else { - output.KeyValue("Profiles", strings.Join(active, ", ")) + key("Profiles", strings.Join(active, ", ")) } if from == "KATES_PROFILES" { output.Hint(" KATES_PROFILES is set, and overrides the context's profiles in this shell.") @@ -574,7 +600,10 @@ func completeProfiles(cmd *cobra.Command, args []string, toComplete string) ([]c out = append(out, cobra.CompletionWithDesc(p, strings.Join(profileCommands(p), ", "))) } } - return append(out, cobra.CompletionWithDesc("all", "every command")), cobra.ShellCompDirectiveNoFileComp + if len(args) == 0 { // all stands alone + out = append(out, cobra.CompletionWithDesc("all", "every command")) + } + return out, cobra.ShellCompDirectiveNoFileComp } func init() { diff --git a/cli/cmd/command_profiles_test.go b/cli/cmd/command_profiles_test.go index d5e63d8d..d22a0619 100644 --- a/cli/cmd/command_profiles_test.go +++ b/cli/cmd/command_profiles_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -50,7 +51,8 @@ func TestActiveProfiles(t *testing.T) { }{ {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 dropped with a warning", stored: []string{"chaso", "chaos"}, want: []string{"chaos"}, wantErr: `Ignoring profile "chaso"`}, + {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"}, @@ -115,7 +117,9 @@ func TestProfileRefusal(t *testing.T) { {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"}}, + 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"}}, } { @@ -124,6 +128,8 @@ func TestProfileRefusal(t *testing.T) { 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"` @@ -144,6 +150,9 @@ func TestProfileRefusal(t *testing.T) { 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()) + } }) } } @@ -153,18 +162,14 @@ func TestProfileHelp(t *testing.T) { t.Cleanup(func() { contextFlag, contextFromEnv = prevFlag, prevEnv }) contextFlag, contextFromEnv = "", false all := profileHelp(helpTemplate, "lab", nil, `context "lab"`) - if !strings.Contains(all, "Context: lab · Profiles: none, every command is enabled") { - t.Errorf("help without profiles does not say so:\n%s", all) - } - for _, line := range strings.Split(helpTemplate, "\n") { - if !strings.Contains(all, line) { - t.Errorf("help without profiles lost %q", line) - } + 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{ - "Context: lab · Profiles: chaos", "trend analysis for Apache Kafka", " disruption ", " test create ", + "\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) { @@ -178,8 +183,8 @@ func TestProfileHelp(t *testing.T) { } 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") { + 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) } @@ -259,6 +264,20 @@ func TestProfilesRefuseAndHideEndToEnd(t *testing.T) { 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) @@ -354,6 +373,11 @@ func TestCtxProfilesLayout(t *testing.T) { } } } + 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) { @@ -413,3 +437,57 @@ func TestCtxProfilesDetails(t *testing.T) { 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 f6ff61b3..9c5b5b61 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -18,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 @@ -440,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/data/doc_entries.yaml b/cli/cmd/data/doc_entries.yaml index 49b7ad85..98dd94d5 100644 --- a/cli/cmd/data/doc_entries.yaml +++ b/cli/cmd/data/doc_entries.yaml @@ -1647,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/root.go b/cli/cmd/root.go index bf9a8cfb..aa7a8cad 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -64,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 @@ -402,7 +407,7 @@ Toolbox: 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) @@ -499,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 @@ -653,15 +659,25 @@ func init() { defaultHelp := rootCmd.HelpFunc() rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if cmd != rootCmd { - defaultHelp(cmd, args) - return - } // 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(profileHelp(cmd.Long, ctxName, profiles, from)) fmt.Println() fmt.Println("Usage:") diff --git a/cli/cmd/tldr.go b/cli/cmd/tldr.go index 152f6b36..e99c2e0f 100644 --- a/cli/cmd/tldr.go +++ b/cli/cmd/tldr.go @@ -45,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 "}, }, @@ -93,8 +93,7 @@ var ( tldrCatStyle = lipgloss.NewStyle(). Bold(true). - Foreground(theme.Secondary). - MarginTop(1) + Foreground(theme.Secondary) ) var tldrCmd = &cobra.Command{ @@ -111,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) && @@ -138,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 fae461cc..c3063268 100644 --- a/docs/book/10-cli-reference.md +++ b/docs/book/10-cli-reference.md @@ -233,7 +233,9 @@ kates ctx profiles set all # every command again 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 ignored with a warning on stderr. `kates ctx set` keeps the profiles of a context it updates, and `kates ctx export` and `kates ctx import` carry them. `kates help ` and `kates --help` show the help of any command. +`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 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)