Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
612 changes: 612 additions & 0 deletions cli/cmd/command_profiles.go

Large diffs are not rendered by default.

493 changes: 493 additions & 0 deletions cli/cmd/command_profiles_test.go

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions cli/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import (
"strings"

"github.com/bmscomp/kates/cli/output"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

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
Expand Down Expand Up @@ -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)
},
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions cli/cmd/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}

Expand Down
4 changes: 3 additions & 1 deletion cli/cmd/context_url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion cli/cmd/ctx_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
39 changes: 38 additions & 1 deletion cli/cmd/data/doc_entries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions cli/cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
109 changes: 87 additions & 22 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 != "" {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion cli/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down
Loading
Loading