diff --git a/internal/app/api.go b/internal/app/api.go index a30c56b9..46457c0e 100644 --- a/internal/app/api.go +++ b/internal/app/api.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "sort" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -109,3 +110,45 @@ func ApplicationEnvironments(ctx context.Context, team, appName string) ([]strin } return ret, nil } + +func TeamApplicationEnvironments(ctx context.Context, team string) ([]string, error) { + _ = `# @genqlient + query TeamApplicationEnvironments($team: Slug!) { + team(slug: $team) { + applications(first: 1000) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.TeamApplicationEnvironments(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, node := range resp.Team.Applications.Nodes { + env := node.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} diff --git a/internal/app/command/flag/flag.go b/internal/app/command/flag/flag.go index b25daca2..eb37eba7 100644 --- a/internal/app/command/flag/flag.go +++ b/internal/app/command/flag/flag.go @@ -3,10 +3,12 @@ package flag import ( "context" "fmt" + "os" "time" activityutil "github.com/nais/cli/internal/activity" "github.com/nais/cli/internal/app" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -17,14 +19,86 @@ type App struct { *flags.GlobalFlags Environment Environments `name:"environment" short:"e" usage:"Filter by environment."` } + +func (a *App) GetTeam() string { + if a == nil { + return "" + } + return string(a.Team) +} + type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - envs, err := naisapi.GetAllEnvironments(ctx) + team := appTeamFromFlags(flags) + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + if team == "" { + return autoCompleteEnvironments(ctx) + } + + if appName := appNameForEnvironmentCompletion(args); appName != "" { + envs, err := app.ApplicationEnvironments(ctx, team, appName) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + } + + envs, err := app.TeamApplicationEnvironments(ctx, team) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } - return envs, "Available environments" + if len(envs) > 0 { + return envs, "Available environments" + } + + return nil, "No environments with applications found for this team." +} + +type teamProvider interface { + GetTeam() string +} + +func appTeamFromFlags(flags any) string { + tp, ok := flags.(teamProvider) + if !ok { + return "" + } + return tp.GetTeam() +} + +func appNameForEnvironmentCompletion(args *naistrix.Arguments) string { + if args.Len() > 0 { + if name := args.Get("name"); name != "" { + return name + } + } + + // Some command/flag combinations (for example app restart with parent sticky flags) + // do not expose positional args through naistrix during completion. + if !isRestartCompletionFromCLIArgs() { + return "" + } + + return appNameFromCLIArgs(os.Args) +} + +func isRestartCompletionFromCLIArgs() bool { + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "app", + []string{"-t", "--team", "-e", "--environment", "--config"}, + "restart", + ) +} + +func appNameFromCLIArgs(argv []string) string { + return cliflags.PositionalArgAfterSubcommand( + argv, + "restart", + []string{"-t", "--team", "-e", "--environment", "--config"}, + ) } type instances []string @@ -40,7 +114,7 @@ func (i *instances) AutoComplete(ctx context.Context, args *naistrix.Arguments, } if len(f.Team) == 0 { - return nil, "Please provide team to auto-complete instances. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete instances. 'nais config set team ', or '--team ' flag." } instances, err := app.GetApplicationInstances(ctx, string(f.Team), args.Get("name"), string(f.Environment)) @@ -87,13 +161,17 @@ func (a *ActivityTypes) AutoComplete(context.Context, *naistrix.Arguments, strin type Env string func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - if args.Len() == 0 { - return autoCompleteEnvironments(ctx) - } - f := flags.(*Log) if len(f.Team) == 0 { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + } + + if args.Len() == 0 { + envs, err := app.TeamApplicationEnvironments(ctx, f.Team) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + return autoCompleteEnvironments(ctx) } envs, err := app.ApplicationEnvironments(ctx, f.Team, args.Get("name")) diff --git a/internal/app/command/issues.go b/internal/app/command/issues.go index cd9c44a4..425de0f0 100644 --- a/internal/app/command/issues.go +++ b/internal/app/command/issues.go @@ -2,9 +2,11 @@ package command import ( "context" + "os" "github.com/nais/cli/internal/app" "github.com/nais/cli/internal/app/command/flag" + "github.com/nais/cli/internal/cliflags" "github.com/nais/naistrix" "github.com/nais/naistrix/output" ) @@ -42,7 +44,12 @@ func issues(parentFlags *flag.App) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete application names. 'nais config set team ', or '--team ' flag." } - apps, err := app.GetApplicationNames(ctx, flags.Team, flags.Environment) + environments := []string(flags.Environment) + if len(environments) == 0 { + environments = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + + apps, err := app.GetApplicationNames(ctx, flags.Team, environments) if err != nil { return nil, "Unable to fetch application names." } diff --git a/internal/app/command/restart.go b/internal/app/command/restart.go index 7b09c2ae..4d5557be 100644 --- a/internal/app/command/restart.go +++ b/internal/app/command/restart.go @@ -2,9 +2,12 @@ package command import ( "context" + "fmt" + "os" "github.com/nais/cli/internal/app" "github.com/nais/cli/internal/app/command/flag" + "github.com/nais/cli/internal/cliflags" "github.com/nais/naistrix" ) @@ -20,6 +23,27 @@ func restart(parentFlags *flag.App) *naistrix.Command { Args: []naistrix.Argument{ {Name: "name"}, }, + AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { + if args.Len() != 0 { + return nil, "" + } + if len(flags.Team) == 0 { + return nil, "Please provide team to auto-complete application names. 'nais config set team ', or '--team ' flag." + } + envs := []string(flags.Environment) + if len(envs) != 1 { + envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + if len(envs) != 1 { + return nil, "Please provide exactly one environment to auto-complete application names. '--environment ' flag." + } + + apps, err := app.GetApplicationNames(ctx, flags.Team, envs) + if err != nil { + return nil, fmt.Sprintf("Unable to fetch application names: %v", err) + } + return apps, "Select an application." + }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { ret, err := app.RestartApp(ctx, flags.Team, args.Get("name"), flags.Environment) if err != nil { diff --git a/internal/cliflags/flags.go b/internal/cliflags/flags.go index 1e894c7b..d7664c29 100644 --- a/internal/cliflags/flags.go +++ b/internal/cliflags/flags.go @@ -2,6 +2,14 @@ package cliflags import "strings" +var defaultValueTakingFlags = []string{ + "-t", + "--team", + "-e", + "--environment", + "--config", +} + // UniqueFlagValues returns unique values for a short/long CLI flag from args. // It supports forms: -e value, --environment value, -e=value, --environment=value. func UniqueFlagValues(args []string, shortFlag, longFlag string) []string { @@ -82,3 +90,154 @@ func CountFlagOccurrences(args []string, shortFlag, longFlag string) int { return count } + +// FirstFlagValue returns the first valid value for a short/long CLI flag from args. +// It supports forms: -t value, --team value, -t=value, --team=value. +func FirstFlagValue(args []string, shortFlag, longFlag string) string { + for i := range args { + arg := args[i] + if arg == "--" { + break + } + + if after, ok := strings.CutPrefix(arg, longFlag+"="); ok { + if after != "" { + return after + } + continue + } + if after, ok := strings.CutPrefix(arg, shortFlag+"="); ok { + if after != "" { + return after + } + continue + } + + if arg == shortFlag || arg == longFlag { + if i+1 < len(args) { + next := args[i+1] + if next != "" && !strings.HasPrefix(next, "-") { + return next + } + } + continue + } + } + + return "" +} + +// HasSubCommandPath reports whether args contain a command path where `parent` +// is followed by one of the provided subcommands as the next non-flag token. +func HasSubCommandPath(args []string, parent string, subcommands ...string) bool { + return HasSubCommandPathWithValueFlags(args, parent, defaultValueTakingFlags, subcommands...) +} + +// HasSubCommandPathWithValueFlags is like HasSubCommandPath, but lets callers +// define which flags consume the next token as a value. +func HasSubCommandPathWithValueFlags(args []string, parent string, valueTakingFlags []string, subcommands ...string) bool { + if len(subcommands) == 0 { + return false + } + + allowed := make(map[string]struct{}, len(subcommands)) + for _, sub := range subcommands { + allowed[sub] = struct{}{} + } + + consumesValue := make(map[string]struct{}, len(valueTakingFlags)) + for _, f := range valueTakingFlags { + consumesValue[f] = struct{}{} + } + + for i := 0; i < len(args); i++ { + if args[i] == "--" { + break + } + // Skip value-taking flags and their values so that a flag value equal + // to parent is not mistaken for a command token (e.g. --team app app restart). + if strings.HasPrefix(args[i], "-") { + if _, takesValue := consumesValue[args[i]]; takesValue && + !strings.Contains(args[i], "=") && + i+1 < len(args) && + args[i+1] != "" && + !strings.HasPrefix(args[i+1], "-") { + i++ // skip the value token + } + continue + } + if args[i] != parent { + continue + } + + for j := i + 1; j < len(args); j++ { + next := args[j] + if next == "--" { + break + } + if strings.HasPrefix(next, "-") { + _, takesValue := consumesValue[next] + if takesValue && !strings.Contains(next, "=") && j+1 < len(args) { + value := args[j+1] + if value != "" && !strings.HasPrefix(value, "-") { + j++ + } + } + continue + } + _, ok := allowed[next] + return ok + } + } + + return false +} + +// PositionalArgAfterSubcommand returns the first positional argument after a +// specific subcommand, skipping known value-taking flags and their values. +func PositionalArgAfterSubcommand(args []string, subcommand string, valueTakingFlags []string) string { + consumesValue := make(map[string]struct{}, len(valueTakingFlags)) + for _, f := range valueTakingFlags { + consumesValue[f] = struct{}{} + } + + seenSubcommand := false +outer: + for i := 0; i < len(args); i++ { + arg := args[i] + + if arg == subcommand { + seenSubcommand = true + continue + } + if !seenSubcommand { + continue + } + + if arg == "--" { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + + for f := range consumesValue { + if strings.HasPrefix(arg, f+"=") { + continue outer + } + } + + if _, ok := consumesValue[arg]; ok { + i++ + continue + } + + if strings.HasPrefix(arg, "-") { + continue + } + + return arg + } + + return "" +} diff --git a/internal/cliflags/flags_test.go b/internal/cliflags/flags_test.go index ad527774..e8a76148 100644 --- a/internal/cliflags/flags_test.go +++ b/internal/cliflags/flags_test.go @@ -164,3 +164,293 @@ func TestCountFlagOccurrences(t *testing.T) { }) } } + +func TestFirstFlagValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + short string + long string + want string + }{ + { + name: "no matching flag", + args: []string{"cmd", "sub"}, + short: "-t", + long: "--team", + want: "", + }, + { + name: "short flag with separate value", + args: []string{"cmd", "-t", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "long flag with separate value", + args: []string{"cmd", "--team", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "short equals form", + args: []string{"cmd", "-t=nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "long equals form", + args: []string{"cmd", "--team=nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "first occurrence wins", + args: []string{"cmd", "--team=nais", "-t", "other"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "missing value with no later value returns empty", + args: []string{"cmd", "--team"}, + short: "-t", + long: "--team", + want: "", + }, + { + name: "flag-like next arg with no later value returns empty", + args: []string{"cmd", "--team", "--environment", "dev"}, + short: "-t", + long: "--team", + want: "", + }, + { + name: "missing value continues scanning to later valid value", + args: []string{"cmd", "--team", "-x", "--team=nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "flag-like next arg continues scanning to later valid value", + args: []string{"cmd", "--team", "--environment", "dev", "-t", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "empty equals value ignored and later value used", + args: []string{"cmd", "--team=", "-t", "nais"}, + short: "-t", + long: "--team", + want: "nais", + }, + { + name: "flags after end-of-flags marker ignored", + args: []string{"cmd", "--", "--team", "nais"}, + short: "-t", + long: "--team", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := FirstFlagValue(tt.args, tt.short, tt.long) + if got != tt.want { + t.Fatalf("FirstFlagValue() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestHasSubCommandPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + parent string + subs []string + wantHas bool + }{ + { + name: "matches parent and subcommand", + args: []string{"nais", "app", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, + { + name: "matches with flag between parent and subcommand", + args: []string{"nais", "job", "--team", "my-team", "trigger"}, + parent: "job", + subs: []string{"trigger"}, + wantHas: true, + }, + { + name: "matches with boolean flag between parent and subcommand", + args: []string{"nais", "app", "--verbose", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, + { + name: "matches one of many subcommands", + args: []string{"nais", "valkey", "list"}, + parent: "valkey", + subs: []string{"credentials", "delete", "get", "list", "update"}, + wantHas: true, + }, + { + name: "returns false for different subcommand", + args: []string{"nais", "app", "list"}, + parent: "app", + subs: []string{"restart"}, + wantHas: false, + }, + { + name: "returns false when token appears as positional argument", + args: []string{"nais", "opensearch", "create", "delete"}, + parent: "opensearch", + subs: []string{"credentials", "delete", "get", "list", "update"}, + wantHas: false, + }, + { + name: "stops at end of flags marker", + args: []string{"nais", "app", "--", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: false, + }, + { + name: "returns false with no subcommands provided", + args: []string{"nais", "app", "restart"}, + parent: "app", + subs: nil, + wantHas: false, + }, + { + name: "unknown flag does not consume following token", + args: []string{"nais", "app", "--unknown", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, + { + name: "parent token as flag value is not mistaken for command", + args: []string{"nais", "--team", "app", "app", "restart"}, + parent: "app", + subs: []string{"restart"}, + wantHas: true, + }, + { + name: "returns false when parent only appears as flag value", + args: []string{"nais", "--team", "app", "secret", "list"}, + parent: "app", + subs: []string{"restart"}, + wantHas: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := HasSubCommandPath(tt.args, tt.parent, tt.subs...) + if got != tt.wantHas { + t.Fatalf("HasSubCommandPath() = %v, want %v", got, tt.wantHas) + } + }) + } +} + +func TestHasSubCommandPathWithValueFlags(t *testing.T) { + t.Parallel() + + t.Run("consumes only configured value-taking flags", func(t *testing.T) { + t.Parallel() + + args := []string{"nais", "job", "--run-name", "manual-run", "trigger"} + if !HasSubCommandPathWithValueFlags(args, "job", []string{"--run-name"}, "trigger") { + t.Fatalf("expected true when --run-name is configured as value-taking flag") + } + }) + + t.Run("does not consume unknown boolean flag", func(t *testing.T) { + t.Parallel() + + args := []string{"nais", "app", "--verbose", "restart"} + if !HasSubCommandPathWithValueFlags(args, "app", []string{"--team"}, "restart") { + t.Fatalf("expected true when unknown flag does not consume next token") + } + }) +} + +func TestPositionalArgAfterSubcommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + subcommand string + valueFlags []string + wantPositional string + }{ + { + name: "returns positional after subcommand", + args: []string{"nais", "app", "restart", "my-app"}, + subcommand: "restart", + valueFlags: []string{"-t", "--team", "-e", "--environment", "--config"}, + wantPositional: "my-app", + }, + { + name: "skips value-taking flags and values", + args: []string{"nais", "job", "trigger", "--run-name", "manual", "my-job"}, + subcommand: "trigger", + valueFlags: []string{"-t", "--team", "-e", "--environment", "--config", "--run-name"}, + wantPositional: "my-job", + }, + { + name: "skips equals form for value-taking flags", + args: []string{"nais", "job", "trigger", "--run-name=manual", "my-job"}, + subcommand: "trigger", + valueFlags: []string{"--run-name"}, + wantPositional: "my-job", + }, + { + name: "supports end-of-flags marker", + args: []string{"nais", "app", "restart", "--", "my-app"}, + subcommand: "restart", + valueFlags: []string{"-t", "--team", "-e", "--environment", "--config"}, + wantPositional: "my-app", + }, + { + name: "returns empty when none found", + args: []string{"nais", "app", "restart", "--team", "nais"}, + subcommand: "restart", + valueFlags: []string{"--team"}, + wantPositional: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := PositionalArgAfterSubcommand(tt.args, tt.subcommand, tt.valueFlags) + if got != tt.wantPositional { + t.Fatalf("PositionalArgAfterSubcommand() = %q, want %q", got, tt.wantPositional) + } + }) + } +} diff --git a/internal/issues/command/flag/flag.go b/internal/issues/command/flag/flag.go index 1f34017b..6fb0f3bf 100644 --- a/internal/issues/command/flag/flag.go +++ b/internal/issues/command/flag/flag.go @@ -33,7 +33,7 @@ func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) type List struct { *Issues - Environment Environment `name:"environment" usage:"Filter issues by environment"` + Environment Environment `name:"environment" short:"e" usage:"Filter issues by environment"` IssueType IssueType `name:"issuetype" usage:"Filter issues by issue type"` ResourceName ResourceName `name:"resourcename" usage:"Filter issues by resource name"` ResourceType ResourceType `name:"resourcetype" usage:"Filter issues by resource type"` diff --git a/internal/job/command/flag/flag.go b/internal/job/command/flag/flag.go index 388514cb..091fe1b8 100644 --- a/internal/job/command/flag/flag.go +++ b/internal/job/command/flag/flag.go @@ -3,9 +3,12 @@ package flag import ( "context" "fmt" + "os" "time" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/naisapi" "github.com/nais/naistrix" ) @@ -15,14 +18,87 @@ type Job struct { Environment Environments `name:"environment" short:"e" usage:"Filter by environment."` } +func (j *Job) GetTeam() string { + if j == nil { + return "" + } + return string(j.Team) +} + type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - envs, err := naisapi.GetAllEnvironments(ctx) + team := jobTeamFromFlags(flags) + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + if team == "" { + envs, err := naisapi.GetAllEnvironments(ctx) + if err != nil { + return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + } + return envs, "Available environments" + } + + if jobName := jobNameForEnvironmentCompletion(args); jobName != "" { + envs, err := job.JobEnvironments(ctx, team, jobName) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + } + + envs, err := job.TeamJobEnvironments(ctx, team) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } - return envs, "Available environments" + if len(envs) > 0 { + return envs, "Available environments" + } + + return nil, "No environments with jobs found for this team." +} + +type teamProvider interface { + GetTeam() string +} + +func jobTeamFromFlags(flags any) string { + tp, ok := flags.(teamProvider) + if !ok { + return "" + } + return tp.GetTeam() +} + +func jobNameForEnvironmentCompletion(args *naistrix.Arguments) string { + if args.Len() > 0 { + if name := args.Get("name"); name != "" { + return name + } + } + + if !isTriggerCompletionFromCLIArgs() { + return "" + } + + return jobNameFromCLIArgs(os.Args) +} + +func isTriggerCompletionFromCLIArgs() bool { + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "job", + []string{"-t", "--team", "-e", "--environment", "--config", "--run-name"}, + "trigger", + ) +} + +func jobNameFromCLIArgs(argv []string) string { + return cliflags.PositionalArgAfterSubcommand( + argv, + "trigger", + []string{"-t", "--team", "-e", "--environment", "--config", "--run-name"}, + ) } type Output string @@ -58,7 +134,35 @@ type Trigger struct { type Env string func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - envs, err := naisapi.GetAllEnvironments(ctx) + var team string + switch t := flags.(type) { + case *Log: + team = string(t.Team) + case *Trigger: + team = string(t.Team) + } + if team == "" { + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + } + if team == "" { + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + } + + if args.Len() == 0 { + envs, err := job.TeamJobEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + envs, err = naisapi.GetAllEnvironments(ctx) + if err != nil { + return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) + } + return envs, "Available environments" + } + + envs, err := job.JobEnvironments(ctx, team, args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } diff --git a/internal/job/command/issues.go b/internal/job/command/issues.go index 3d12bb80..52e0939f 100644 --- a/internal/job/command/issues.go +++ b/internal/job/command/issues.go @@ -2,7 +2,9 @@ package command import ( "context" + "os" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/job/command/flag" "github.com/nais/naistrix" @@ -40,7 +42,12 @@ func issues(parentFlags *flag.Job) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete job names. 'nais config set team ', or '--team ' flag." } - jobs, err := job.GetJobNames(ctx, flags.Team, flags.Environment) + environments := []string(flags.Environment) + if len(environments) == 0 { + environments = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + + jobs, err := job.GetJobNames(ctx, flags.Team, environments) if err != nil { return nil, "Unable to fetch job names." } diff --git a/internal/job/command/log.go b/internal/job/command/log.go index 77c66ef9..afcd29ab 100644 --- a/internal/job/command/log.go +++ b/internal/job/command/log.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "os" "strings" "sync/atomic" "time" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/job/command/flag" logs "github.com/nais/cli/internal/log/command" @@ -99,10 +101,14 @@ func log(parentFlags *flag.Job) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete job names. 'nais config set team ', or '--team ' flag." } + envs := []string{string(flags.Environment)} if flags.Environment == "" { + envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + if len(envs) != 1 { return nil, "Please provide environment to auto-complete job names. '--environment ' flag." } - jobs, err := job.GetJobNames(ctx, flags.Team, []string{string(flags.Environment)}) + jobs, err := job.GetJobNames(ctx, flags.Team, envs) if err != nil { return nil, "Unable to fetch job names." } diff --git a/internal/job/command/trigger.go b/internal/job/command/trigger.go index 9460afcd..6ea3cbb5 100644 --- a/internal/job/command/trigger.go +++ b/internal/job/command/trigger.go @@ -3,7 +3,9 @@ package command import ( "context" "fmt" + "os" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/job" "github.com/nais/cli/internal/job/command/flag" "github.com/nais/naistrix" @@ -39,10 +41,14 @@ func trigger(parentFlags *flag.Job) *naistrix.Command { if len(flags.Team) == 0 { return nil, "Please provide team to auto-complete job names. 'nais config set team ', or '--team ' flag." } + envs := []string{string(flags.Environment)} if flags.Environment == "" { + envs = cliflags.UniqueFlagValues(os.Args, "-e", "--environment") + } + if len(envs) != 1 { return nil, "Please provide environment to auto-complete job names. '--environment ' flag." } - jobs, err := job.GetJobNames(ctx, flags.Team, []string{string(flags.Environment)}) + jobs, err := job.GetJobNames(ctx, flags.Team, envs) if err != nil { return nil, "Unable to fetch job names." } diff --git a/internal/job/list.go b/internal/job/list.go index ec0c9176..1e5722b2 100644 --- a/internal/job/list.go +++ b/internal/job/list.go @@ -105,6 +105,46 @@ func GetJobNames(ctx context.Context, team string, environments []string) ([]str return ret, nil } +func TeamJobEnvironments(ctx context.Context, team string) ([]string, error) { + return jobEnvironments(ctx, team, "") +} + +func JobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { + return jobEnvironments(ctx, team, jobName) +} + +// jobEnvironments fetches all jobs for the team and returns the unique sorted +// environments, optionally filtered to a specific job name client-side. +// Server-side name filtering is not currently supported by the API. +func jobEnvironments(ctx context.Context, team, jobName string) ([]string, error) { + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetJobNames(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, j := range resp.Team.Jobs.Nodes { + if jobName != "" && j.Name != jobName { + continue + } + env := j.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} + func GetTeamJobs(ctx context.Context, team string, environments []string) ([]Job, error) { _ = `# @genqlient query GetTeamJobs($team: Slug!, $orderBy: JobOrder) { diff --git a/internal/kafka/command/flag/flag.go b/internal/kafka/command/flag/flag.go index a0b9f668..cdb3cf5a 100644 --- a/internal/kafka/command/flag/flag.go +++ b/internal/kafka/command/flag/flag.go @@ -3,8 +3,11 @@ package flag import ( "context" "fmt" + "os" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" + "github.com/nais/cli/internal/kafka" "github.com/nais/cli/internal/naisapi" "github.com/nais/naistrix" ) @@ -17,6 +20,17 @@ type Kafka struct { type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { + team := kafkaTeamFromFlags(flags) + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + if team != "" { + envs, err := kafka.TeamTopicEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments" + } + } + envs, err := naisapi.GetAllEnvironments(ctx) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) @@ -24,6 +38,19 @@ func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Argument return envs, "Available environments" } +func kafkaTeamFromFlags(flags any) string { + switch f := flags.(type) { + case *List: + return string(f.Team) + case *Credentials: + return string(f.Team) + case *Kafka: + return string(f.Team) + default: + return "" + } +} + type Output string var _ naistrix.FlagAutoCompleter = (*Output)(nil) diff --git a/internal/kafka/kafka.go b/internal/kafka/kafka.go index 23570732..4765ef25 100644 --- a/internal/kafka/kafka.go +++ b/internal/kafka/kafka.go @@ -64,3 +64,29 @@ func GetTeamTopics(ctx context.Context, team string, environments []string) ([]T return ret, nil } + +func TeamTopicEnvironments(ctx context.Context, team string) ([]string, error) { + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetTeamKafkaTopics(ctx, client, team) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + ret := make([]string, 0) + for _, topic := range resp.Team.KafkaTopics.Nodes { + env := topic.TeamEnvironment.Environment.Name + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + + sort.Strings(ret) + return ret, nil +} diff --git a/internal/naisapi/gql/generated.go b/internal/naisapi/gql/generated.go index 9176af86..835e00dd 100644 --- a/internal/naisapi/gql/generated.go +++ b/internal/naisapi/gql/generated.go @@ -3882,6 +3882,85 @@ func (v *GetAllOpenSearchesTeamOpenSearchesOpenSearchConnectionNodesOpenSearchVe return v.Actual } +// GetAllSecretEnvironmentsResponse is returned by GetAllSecretEnvironments on success. +type GetAllSecretEnvironmentsResponse struct { + // Get a team by its slug. + Team GetAllSecretEnvironmentsTeam `json:"team"` +} + +// GetTeam returns GetAllSecretEnvironmentsResponse.Team, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsResponse) GetTeam() GetAllSecretEnvironmentsTeam { return v.Team } + +// GetAllSecretEnvironmentsTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type GetAllSecretEnvironmentsTeam struct { + // Secrets owned by the team. + Secrets GetAllSecretEnvironmentsTeamSecretsSecretConnection `json:"secrets"` +} + +// GetSecrets returns GetAllSecretEnvironmentsTeam.Secrets, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeam) GetSecrets() GetAllSecretEnvironmentsTeamSecretsSecretConnection { + return v.Secrets +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnection includes the requested fields of the GraphQL type SecretConnection. +type GetAllSecretEnvironmentsTeamSecretsSecretConnection struct { + // List of nodes. + Nodes []GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret `json:"nodes"` +} + +// GetNodes returns GetAllSecretEnvironmentsTeamSecretsSecretConnection.Nodes, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnection) GetNodes() []GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret { + return v.Nodes +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret includes the requested fields of the GraphQL type Secret. +// The GraphQL type's documentation follows. +// +// A secret is a collection of secret values. +type GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret struct { + // The environment the secret exists in. + TeamEnvironment GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment `json:"teamEnvironment"` +} + +// GetTeamEnvironment returns GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret) GetTeamEnvironment() GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment { + return v.TeamEnvironment +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment struct { + // Get the environment. + Environment GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment `json:"environment"` +} + +// GetEnvironment returns GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironment) GetEnvironment() GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment { + return v.Environment +} + +// GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// The GraphQL type's documentation follows. +// +// An environment represents a runtime environment for workloads. +// +// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). +type GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment struct { + // Unique name of the environment. + Name string `json:"name"` +} + +// GetName returns GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecretTeamEnvironmentEnvironment) GetName() string { + return v.Name +} + // GetAllSecretsResponse is returned by GetAllSecrets on success. type GetAllSecretsResponse struct { // Get a team by its slug. @@ -21956,6 +22035,92 @@ type TailLogResponse struct { // GetLog returns TailLogResponse.Log, and is useful for accessing the field via an interface. func (v *TailLogResponse) GetLog() TailLogLogLogLine { return v.Log } +// TeamApplicationEnvironmentsResponse is returned by TeamApplicationEnvironments on success. +type TeamApplicationEnvironmentsResponse struct { + // Get a team by its slug. + Team TeamApplicationEnvironmentsTeam `json:"team"` +} + +// GetTeam returns TeamApplicationEnvironmentsResponse.Team, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsResponse) GetTeam() TeamApplicationEnvironmentsTeam { + return v.Team +} + +// TeamApplicationEnvironmentsTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// The team type represents a team on the [Nais platform](https://nais.io/). +// +// Learn more about what Nais teams are and what they can be used for in the [official Nais documentation](https://docs.nais.io/explanations/team/). +// +// External resources (e.g. entraIDGroupID, gitHubTeamSlug) are managed by [Nais API reconcilers](https://github.com/nais/api-reconcilers). +type TeamApplicationEnvironmentsTeam struct { + // Nais applications owned by the team. + Applications TeamApplicationEnvironmentsTeamApplicationsApplicationConnection `json:"applications"` +} + +// GetApplications returns TeamApplicationEnvironmentsTeam.Applications, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeam) GetApplications() TeamApplicationEnvironmentsTeamApplicationsApplicationConnection { + return v.Applications +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnection includes the requested fields of the GraphQL type ApplicationConnection. +// The GraphQL type's documentation follows. +// +// Application connection. +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnection struct { + // List of nodes. + Nodes []TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication `json:"nodes"` +} + +// GetNodes returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnection.Nodes, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnection) GetNodes() []TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication { + return v.Nodes +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication includes the requested fields of the GraphQL type Application. +// The GraphQL type's documentation follows. +// +// An application lets you run one or more instances of a container image on the [Nais platform](https://nais.io/). +// +// Learn more about how to create and configure your applications in the [Nais documentation](https://docs.nais.io/workloads/application/). +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication struct { + // The team environment for the application. + TeamEnvironment TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment `json:"teamEnvironment"` +} + +// GetTeamEnvironment returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication.TeamEnvironment, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplication) GetTeamEnvironment() TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment { + return v.TeamEnvironment +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment includes the requested fields of the GraphQL type TeamEnvironment. +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment struct { + // Get the environment. + Environment TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment `json:"environment"` +} + +// GetEnvironment returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment.Environment, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironment) GetEnvironment() TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment { + return v.Environment +} + +// TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment includes the requested fields of the GraphQL type Environment. +// The GraphQL type's documentation follows. +// +// An environment represents a runtime environment for workloads. +// +// Learn more in the [official Nais documentation](https://docs.nais.io/workloads/explanations/environment/). +type TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment struct { + // Unique name of the environment. + Name string `json:"name"` +} + +// GetName returns TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment.Name, and is useful for accessing the field via an interface. +func (v *TeamApplicationEnvironmentsTeamApplicationsApplicationConnectionNodesApplicationTeamEnvironmentEnvironment) GetName() string { + return v.Name +} + // Input for filtering the applications of a team. type TeamApplicationsFilter struct { // Input for filtering the applications of a team. @@ -24000,6 +24165,14 @@ type __GetAllOpenSearchesInput struct { // GetTeamSlug returns __GetAllOpenSearchesInput.TeamSlug, and is useful for accessing the field via an interface. func (v *__GetAllOpenSearchesInput) GetTeamSlug() string { return v.TeamSlug } +// __GetAllSecretEnvironmentsInput is used internally by genqlient +type __GetAllSecretEnvironmentsInput struct { + TeamSlug string `json:"teamSlug"` +} + +// GetTeamSlug returns __GetAllSecretEnvironmentsInput.TeamSlug, and is useful for accessing the field via an interface. +func (v *__GetAllSecretEnvironmentsInput) GetTeamSlug() string { return v.TeamSlug } + // __GetAllSecretsInput is used internally by genqlient type __GetAllSecretsInput struct { TeamSlug string `json:"teamSlug"` @@ -24412,6 +24585,14 @@ func (v *__TailLogInput) GetLimit() int { return v.Limit } // GetStart returns __TailLogInput.Start, and is useful for accessing the field via an interface. func (v *__TailLogInput) GetStart() time.Time { return v.Start } +// __TeamApplicationEnvironmentsInput is used internally by genqlient +type __TeamApplicationEnvironmentsInput struct { + Team string `json:"team"` +} + +// GetTeam returns __TeamApplicationEnvironmentsInput.Team, and is useful for accessing the field via an interface. +func (v *__TeamApplicationEnvironmentsInput) GetTeam() string { return v.Team } + // __TeamMembersInput is used internally by genqlient type __TeamMembersInput struct { Slug string `json:"slug"` @@ -25331,6 +25512,48 @@ func GetAllOpenSearches( return data_, err_ } +// The query executed by GetAllSecretEnvironments. +const GetAllSecretEnvironments_Operation = ` +query GetAllSecretEnvironments ($teamSlug: Slug!) { + team(slug: $teamSlug) { + secrets(first: 1000, orderBy: {field:NAME,direction:ASC}) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } +} +` + +func GetAllSecretEnvironments( + ctx_ context.Context, + client_ graphql.Client, + teamSlug string, +) (data_ *GetAllSecretEnvironmentsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAllSecretEnvironments", + Query: GetAllSecretEnvironments_Operation, + Variables: &__GetAllSecretEnvironmentsInput{ + TeamSlug: teamSlug, + }, + } + + data_ = &GetAllSecretEnvironmentsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by GetAllSecrets. const GetAllSecrets_Operation = ` query GetAllSecrets ($teamSlug: Slug!) { @@ -26889,6 +27112,48 @@ func TailLogForwardData(interfaceChan interface{}, jsonRawMsg json.RawMessage) e return nil } +// The query executed by TeamApplicationEnvironments. +const TeamApplicationEnvironments_Operation = ` +query TeamApplicationEnvironments ($team: Slug!) { + team(slug: $team) { + applications(first: 1000) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } +} +` + +func TeamApplicationEnvironments( + ctx_ context.Context, + client_ graphql.Client, + team string, +) (data_ *TeamApplicationEnvironmentsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TeamApplicationEnvironments", + Query: TeamApplicationEnvironments_Operation, + Variables: &__TeamApplicationEnvironmentsInput{ + Team: team, + }, + } + + data_ = &TeamApplicationEnvironmentsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The query executed by TeamMembers. const TeamMembers_Operation = ` query TeamMembers ($slug: Slug!) { diff --git a/internal/opensearch/command/flag/flag.go b/internal/opensearch/command/flag/flag.go index f1b90e20..f251b58f 100644 --- a/internal/opensearch/command/flag/flag.go +++ b/internal/opensearch/command/flag/flag.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "os" - "slices" "sort" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -30,17 +30,22 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st team = f.Team } - if team != "" && isCredentialsCompletionFromCLIArgs() { + if team != "" && isInstanceEnvironmentCompletionFromCLIArgs() { envs, err := opensearchCredentialEnvironments(ctx, team) - if err == nil { + if err == nil && len(envs) > 0 { return envs, "Available environments with OpenSearch instances" } } return autoCompleteEnvironments(ctx) } -func isCredentialsCompletionFromCLIArgs() bool { - return slices.Contains(os.Args, "credentials") +func isInstanceEnvironmentCompletionFromCLIArgs() bool { + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "opensearch", + []string{"-t", "--team", "-e", "--environment", "--config"}, + "credentials", "delete", "get", "list", "update", + ) } func opensearchCredentialEnvironments(ctx context.Context, team string) ([]string, error) { @@ -92,13 +97,17 @@ type Delete struct { type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { - if args.Len() == 0 { - return autoCompleteEnvironments(ctx) - } - f := flags.(*Get) if len(f.Team) == 0 { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." + } + + if args.Len() == 0 { + envs, err := opensearchCredentialEnvironments(ctx, f.Team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with OpenSearch instances" + } + return autoCompleteEnvironments(ctx) } envs, err := opensearch.OpenSearchEnvironments(ctx, f.Team, args.Get("name")) @@ -118,6 +127,14 @@ type Output string type Environments []string func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { + f, ok := flags.(*List) + if ok && f.Team != "" { + envs, err := opensearchCredentialEnvironments(ctx, f.Team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with OpenSearch instances" + } + } + return autoCompleteEnvironments(ctx) } diff --git a/internal/secret/activity.go b/internal/secret/activity.go index d279ac60..49c34268 100644 --- a/internal/secret/activity.go +++ b/internal/secret/activity.go @@ -3,6 +3,7 @@ package secret import ( "context" "slices" + "sort" "time" "github.com/nais/cli/internal/naisapi" @@ -118,5 +119,9 @@ func buildSecretActivity(resources []secretActivityResource, name string, enviro } } + sort.SliceStable(ret, func(i, j int) bool { + return ret[i].CreatedAt.After(ret[j].CreatedAt) + }) + return ret, found } diff --git a/internal/secret/activity_test.go b/internal/secret/activity_test.go index fcb5518a..e183d873 100644 --- a/internal/secret/activity_test.go +++ b/internal/secret/activity_test.go @@ -64,6 +64,31 @@ func TestBuildSecretActivity(t *testing.T) { wantFound: false, want: []SecretActivity{}, }, + { + name: "sorted by created time descending", + secretName: "db-credentials", + resources: []secretActivityResource{ + { + Name: "db-credentials", + DefaultEnvName: "dev-gcp", + Entries: []secretActivityEntry{ + {CreatedAt: now.Add(-2 * time.Hour), Actor: "alice@example.com", Message: "Older event", EnvironmentName: ""}, + }, + }, + { + Name: "db-credentials", + DefaultEnvName: "prod-gcp", + Entries: []secretActivityEntry{ + {CreatedAt: now, Actor: "bob@example.com", Message: "Newest event", EnvironmentName: ""}, + }, + }, + }, + wantFound: true, + want: []SecretActivity{ + {CreatedAt: now, Actor: "bob@example.com", Environment: "prod-gcp", Message: "Newest event"}, + {CreatedAt: now.Add(-2 * time.Hour), Actor: "alice@example.com", Environment: "dev-gcp", Message: "Older event"}, + }, + }, } for _, tt := range tests { diff --git a/internal/secret/command/environment.go b/internal/secret/command/environment.go index 8f9af0dc..a80afbef 100644 --- a/internal/secret/command/environment.go +++ b/internal/secret/command/environment.go @@ -1,51 +1,12 @@ package command import ( - "context" "fmt" "os" - "slices" - "sort" - "strings" "github.com/nais/cli/internal/cliflags" - "github.com/nais/cli/internal/secret" ) -func resolveSecretEnvironment(ctx context.Context, team, name, provided string) (string, error) { - envs, err := secret.SecretEnvironments(ctx, team, name) - if err != nil { - return "", fmt.Errorf("fetching environments for secret %q: %w", name, err) - } - - return selectSecretEnvironment(team, name, provided, envs) -} - -func selectSecretEnvironment(team, name, provided string, envs []string) (string, error) { - if provided != "" { - if slices.Contains(envs, provided) { - return provided, nil - } - - if len(envs) == 0 { - return "", fmt.Errorf("secret %q not found in team %q", name, team) - } - - sort.Strings(envs) - return "", fmt.Errorf("secret %q does not exist in environment %q; available environments: %s", name, provided, strings.Join(envs, ", ")) - } - - switch len(envs) { - case 0: - return "", fmt.Errorf("secret %q not found in team %q", name, team) - case 1: - return envs[0], nil - default: - sort.Strings(envs) - return "", fmt.Errorf("secret %q exists in multiple environments (%s); specify --environment/-e", name, strings.Join(envs, ", ")) - } -} - func validateSingleEnvironmentFlagUsage() error { if countEnvironmentFlagsInCLIArgs() > 1 { return fmt.Errorf("only one --environment/-e flag may be provided") diff --git a/internal/secret/command/environment_test.go b/internal/secret/command/environment_test.go deleted file mode 100644 index 257b14c3..00000000 --- a/internal/secret/command/environment_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package command - -import "testing" - -func TestSelectSecretEnvironment(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - team string - secret string - provided string - envs []string - wantEnv string - wantError string - }{ - { - name: "provided environment exists", - team: "nais", - secret: "my-secret", - provided: "dev-gcp", - envs: []string{"dev-gcp", "prod-gcp"}, - wantEnv: "dev-gcp", - }, - { - name: "provided environment missing with alternatives", - team: "nais", - secret: "my-secret", - provided: "staging-gcp", - envs: []string{"prod-gcp", "dev-gcp"}, - wantError: "secret \"my-secret\" does not exist in environment \"staging-gcp\"; available environments: dev-gcp, prod-gcp", - }, - { - name: "provided environment missing and secret absent", - team: "nais", - secret: "my-secret", - provided: "dev-gcp", - envs: nil, - wantError: "secret \"my-secret\" not found in team \"nais\"", - }, - { - name: "no provided and no environments", - team: "nais", - secret: "my-secret", - envs: nil, - wantError: "secret \"my-secret\" not found in team \"nais\"", - }, - { - name: "no provided and one environment", - team: "nais", - secret: "my-secret", - envs: []string{"dev-gcp"}, - wantEnv: "dev-gcp", - }, - { - name: "no provided and multiple environments", - team: "nais", - secret: "my-secret", - envs: []string{"prod-gcp", "dev-gcp"}, - wantError: "secret \"my-secret\" exists in multiple environments (dev-gcp, prod-gcp); specify --environment/-e", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - gotEnv, err := selectSecretEnvironment(tt.team, tt.secret, tt.provided, tt.envs) - if tt.wantError != "" { - if err == nil { - t.Fatalf("expected error %q, got nil", tt.wantError) - } - if err.Error() != tt.wantError { - t.Fatalf("error = %q, want %q", err.Error(), tt.wantError) - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if gotEnv != tt.wantEnv { - t.Fatalf("env = %q, want %q", gotEnv, tt.wantEnv) - } - }) - } -} diff --git a/internal/secret/command/flag/flag.go b/internal/secret/command/flag/flag.go index c167846d..04958ba1 100644 --- a/internal/secret/command/flag/flag.go +++ b/internal/secret/command/flag/flag.go @@ -3,8 +3,10 @@ package flag import ( "context" "fmt" + "os" activityutil "github.com/nais/cli/internal/activity" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -17,32 +19,60 @@ type Secret struct { Environment Env `name:"environment" short:"e" usage:"Filter by environment."` } +type teamProvider interface { + GetTeam() string +} + +func (s *Secret) GetTeam() string { return string(s.Team) } + type Env string -func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, _ any) ([]string, string) { +func (e *Env) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { + team := secretTeamFromFlags(flags) + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + if team != "" { + envs, err := secret.TeamSecretEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with secrets" + } + } + return autoCompleteEnvironments(ctx) } -// GetEnv is like Env but provides context-aware autocomplete: when a secret -// name argument has been provided, only environments where that secret exists -// are suggested. +// GetEnv is like Env but provides context-aware autocomplete: +// - when no secret name argument is provided, environments where the team has +// at least one secret are suggested (falling back to all platform environments) +// - when a secret name argument has been provided, only environments where +// that specific secret exists are suggested. type GetEnv string func (e *GetEnv) AutoComplete(ctx context.Context, args *naistrix.Arguments, _ string, flags any) ([]string, string) { - if args.Len() == 0 { - return autoCompleteEnvironments(ctx) + tp, ok := flags.(teamProvider) + if !ok { + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } - type teamProvider interface { - GetTeam() string + team := tp.GetTeam() + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam } - tp, ok := flags.(teamProvider) - if !ok || tp.GetTeam() == "" { - return nil, "Please provide team to auto-complete environments. 'nais config team set ', or '--team ' flag." + if team == "" { + return nil, "Please provide team to auto-complete environments. 'nais config set team ', or '--team ' flag." } - envs, err := secret.SecretEnvironments(ctx, tp.GetTeam(), args.Get("name")) + if args.Len() == 0 { + envs, err := secret.TeamSecretEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with secrets" + } + return autoCompleteEnvironments(ctx) + } + + envs, err := secret.SecretEnvironments(ctx, team, args.Get("name")) if err != nil { return nil, fmt.Sprintf("Failed to fetch environments for auto-completion: %v", err) } @@ -59,10 +89,29 @@ func autoCompleteEnvironments(ctx context.Context) ([]string, string) { type Environments []string -func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, _ any) ([]string, string) { +func (e *Environments) AutoComplete(ctx context.Context, _ *naistrix.Arguments, _ string, flags any) ([]string, string) { + team := secretTeamFromFlags(flags) + if cliTeam := cliflags.FirstFlagValue(os.Args, "-t", "--team"); cliTeam != "" { + team = cliTeam + } + if team != "" { + envs, err := secret.TeamSecretEnvironments(ctx, team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with secrets" + } + } + return autoCompleteEnvironments(ctx) } +func secretTeamFromFlags(flags any) string { + tp, ok := flags.(teamProvider) + if !ok { + return "" + } + return tp.GetTeam() +} + type Output string func (o *Output) AutoComplete(context.Context, *naistrix.Arguments, string, any) ([]string, string) { @@ -97,8 +146,6 @@ type Get struct { Reason string `name:"reason" usage:"Reason for accessing secret values (min 10 chars). Used with --with-values."` } -func (g *Get) GetTeam() string { return string(g.Team) } - type Create struct { *Secret } @@ -109,8 +156,6 @@ type Delete struct { Yes bool `name:"yes" short:"y" usage:"Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively."` } -func (d *Delete) GetTeam() string { return string(d.Team) } - type Set struct { *Secret Environment GetEnv `name:"environment" short:"e" usage:"Filter by environment."` @@ -119,13 +164,9 @@ type Set struct { ValueFromStdin bool `name:"value-from-stdin" usage:"Read value from stdin."` } -func (s *Set) GetTeam() string { return string(s.Team) } - type Unset struct { *Secret Environment GetEnv `name:"environment" short:"e" usage:"Filter by environment."` Key string `name:"key" usage:"Name of the key to unset."` Yes bool `name:"yes" short:"y" usage:"Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively."` } - -func (u *Unset) GetTeam() string { return string(u.Team) } diff --git a/internal/secret/command/get.go b/internal/secret/command/get.go index 48990784..b6a3c958 100644 --- a/internal/secret/command/get.go +++ b/internal/secret/command/get.go @@ -13,17 +13,10 @@ import ( "github.com/pterm/pterm" ) -// Entry represents a key-value pair in a secret. When values are not fetched, -// the Value field is empty and omitted from JSON output. -type Entry struct { - Key string `json:"key"` - Value string `json:"value,omitempty"` -} - type SecretDetail struct { Name string `json:"name"` Environment string `json:"environment"` - Data []Entry `json:"data"` + Data []secret.Entry `json:"data"` LastModified secret.LastModified `json:"lastModified"` ModifiedBy string `json:"modifiedBy,omitempty"` Workloads []string `json:"workloads,omitempty"` @@ -34,17 +27,16 @@ func get(parentFlags *flag.Secret) *naistrix.Command { return &naistrix.Command{ Name: "get", Title: "Get details about a secret.", - Description: "This command shows details about a secret, including its keys, workloads using it, and last modification info. Use --with-values to also fetch and display the actual secret values (access is logged for auditing).", + Description: "This command shows details about a secret, including its keys, workloads using it, and last modification info. The --environment/-e flag is required. Use --with-values to also fetch and display the actual secret values (access is logged for auditing).", Flags: f, Args: defaultArgs, ValidateFunc: func(_ context.Context, args *naistrix.Arguments) error { if err := validateSingleEnvironmentFlagUsage(); err != nil { return err } - if providedEnvironment := string(f.Environment); providedEnvironment != "" { - if err := validation.CheckEnvironment(providedEnvironment); err != nil { - return err - } + providedEnvironment := string(f.Environment) + if err := validation.CheckEnvironment(providedEnvironment); err != nil { + return err } if err := validateArgs(args); err != nil { return err @@ -59,7 +51,7 @@ func get(parentFlags *flag.Secret) *naistrix.Command { }, AutoCompleteFunc: func(ctx context.Context, args *naistrix.Arguments, _ string) ([]string, string) { if args.Len() == 0 { - return autoCompleteSecretNames(ctx, f.Team, string(f.Environment), false) + return autoCompleteSecretNames(ctx, f.Team, string(f.Environment), true) } return nil, "" }, @@ -78,16 +70,7 @@ func get(parentFlags *flag.Secret) *naistrix.Command { }, }, RunFunc: func(ctx context.Context, args *naistrix.Arguments, out *naistrix.OutputWriter) error { - if providedEnvironment := string(f.Environment); providedEnvironment != "" { - return runGetCommand(ctx, args, out, f.Team, providedEnvironment, f.Output, f.WithValues, f.Reason) - } - - environment, err := resolveSecretEnvironment(ctx, f.Team, args.Get("name"), string(f.Environment)) - if err != nil { - return err - } - - return runGetCommand(ctx, args, out, f.Team, environment, f.Output, f.WithValues, f.Reason) + return runGetCommand(ctx, args, out, f.Team, string(f.Environment), f.Output, f.WithValues, f.Reason) }, } } @@ -100,9 +83,9 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. return fmt.Errorf("fetching secret: %w", err) } - entries := make([]Entry, len(existing.Keys)) + entries := make([]secret.Entry, len(existing.Keys)) for i, k := range existing.Keys { - entries[i] = Entry{Key: k} + entries[i] = secret.Entry{Key: k} } if withValues { @@ -165,11 +148,7 @@ func runGetCommand(ctx context.Context, args *naistrix.Arguments, out *naistrix. if len(entries) > 0 { var data [][]string if withValues { - secretEntries := make([]secret.Entry, len(entries)) - for i, e := range entries { - secretEntries[i] = secret.Entry{Key: e.Key, Value: e.Value} - } - data = secret.FormatDataWithValues(secretEntries) + data = secret.FormatDataWithValues(entries) } else { data = secret.FormatData(existing.Keys) } diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 59e29b82..71eeaedc 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "sort" "time" "github.com/nais/cli/internal/naisapi" @@ -70,6 +71,65 @@ func SecretEnvironments(ctx context.Context, teamSlug, name string) ([]string, e return envs, nil } +// TeamSecretEnvironments returns unique sorted environments where the team has one or more secrets. +func TeamSecretEnvironments(ctx context.Context, teamSlug string) ([]string, error) { + all, err := getAllSecretEnvironments(ctx, teamSlug) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(all)) + for _, s := range all { + names = append(names, s.TeamEnvironment.Environment.Name) + } + return uniqueSortedEnvironments(names), nil +} + +// uniqueSortedEnvironments returns a deduplicated, sorted slice of environment names. +func uniqueSortedEnvironments(envs []string) []string { + seen := make(map[string]struct{}) + ret := make([]string, 0, len(envs)) + for _, env := range envs { + if _, ok := seen[env]; ok { + continue + } + seen[env] = struct{}{} + ret = append(ret, env) + } + sort.Strings(ret) + return ret +} + +func getAllSecretEnvironments(ctx context.Context, teamSlug string) ([]gql.GetAllSecretEnvironmentsTeamSecretsSecretConnectionNodesSecret, error) { + _ = `# @genqlient + query GetAllSecretEnvironments($teamSlug: Slug!) { + team(slug: $teamSlug) { + secrets(first: 1000, orderBy: {field: NAME, direction: ASC}) { + nodes { + teamEnvironment { + environment { + name + } + } + } + } + } + } + ` + + client, err := naisapi.GraphqlClient(ctx) + if err != nil { + return nil, err + } + + resp, err := gql.GetAllSecretEnvironments(ctx, client, teamSlug) + if err != nil { + return nil, err + } + + return resp.Team.Secrets.Nodes, nil +} + // GetAll retrieves all secrets for a team. func GetAll(ctx context.Context, teamSlug string) ([]gql.GetAllSecretsTeamSecretsSecretConnectionNodesSecret, error) { _ = `# @genqlient @@ -312,8 +372,8 @@ func FormatDetails(metadata Metadata, s *gql.GetSecretTeamEnvironmentSecret) [][ // Entry represents a key-value pair in a secret. When values have not been // fetched, Value is empty. type Entry struct { - Key string - Value string + Key string `json:"key"` + Value string `json:"value,omitempty"` } // FormatData formats secret keys as a key-only table for pterm rendering. diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go index 5a85bee3..d43e99fe 100644 --- a/internal/secret/secret_test.go +++ b/internal/secret/secret_test.go @@ -298,3 +298,49 @@ func TestLastModified_MarshalJSON(t *testing.T) { t.Errorf("MarshalJSON() zero = %s, want %q", data, "") } } + +func TestUniqueSortedEnvironments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + envs []string + want []string + }{ + { + name: "deduplicates and sorts", + envs: []string{"prod-gcp", "dev-gcp", "prod-gcp", "staging", "dev-gcp"}, + want: []string{"dev-gcp", "prod-gcp", "staging"}, + }, + { + name: "empty input returns empty slice", + envs: []string{}, + want: []string{}, + }, + { + name: "single entry", + envs: []string{"dev-gcp"}, + want: []string{"dev-gcp"}, + }, + { + name: "all duplicates", + envs: []string{"dev-gcp", "dev-gcp", "dev-gcp"}, + want: []string{"dev-gcp"}, + }, + { + name: "already sorted unique", + envs: []string{"dev-gcp", "prod-gcp", "staging"}, + want: []string{"dev-gcp", "prod-gcp", "staging"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := uniqueSortedEnvironments(tt.envs) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("uniqueSortedEnvironments() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/valkey/command/flag/flag.go b/internal/valkey/command/flag/flag.go index 36f7f831..04a58fb2 100644 --- a/internal/valkey/command/flag/flag.go +++ b/internal/valkey/command/flag/flag.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "os" - "slices" "sort" + "github.com/nais/cli/internal/cliflags" "github.com/nais/cli/internal/flags" "github.com/nais/cli/internal/naisapi" "github.com/nais/cli/internal/naisapi/gql" @@ -22,6 +22,14 @@ type Valkey struct { } func (e *Environments) AutoComplete(ctx context.Context, args *naistrix.Arguments, str string, flags any) ([]string, string) { + f, ok := flags.(*List) + if ok && f.Team != "" { + envs, err := valkeyCredentialEnvironments(ctx, f.Team) + if err == nil && len(envs) > 0 { + return envs, "Available environments with Valkey instances" + } + } + return autoCompleteEnvironments(ctx) } @@ -87,17 +95,22 @@ func (e *Env) AutoComplete(ctx context.Context, args *naistrix.Arguments, str st team = f.Team } - if team != "" && isCredentialsCompletionFromCLIArgs() { + if team != "" && isInstanceEnvironmentCompletionFromCLIArgs() { envs, err := valkeyCredentialEnvironments(ctx, team) - if err == nil { + if err == nil && len(envs) > 0 { return envs, "Available environments with Valkey instances" } } return autoCompleteEnvironments(ctx) } -func isCredentialsCompletionFromCLIArgs() bool { - return slices.Contains(os.Args, "credentials") +func isInstanceEnvironmentCompletionFromCLIArgs() bool { + return cliflags.HasSubCommandPathWithValueFlags( + os.Args, + "valkey", + []string{"-t", "--team", "-e", "--environment", "--config"}, + "credentials", "delete", "get", "list", "update", + ) } func valkeyCredentialEnvironments(ctx context.Context, team string) ([]string, error) {