From 5c31e39247c6e2eafe67de30b2ae0e4b2c9a5806 Mon Sep 17 00:00:00 2001 From: Julio Rodriguez Date: Fri, 19 Jun 2026 12:05:52 -0600 Subject: [PATCH] fix(pricing): honor AWS profile and reuse resolved credentials The pricing provider built a brand-new AWS config via LoadDefaultConfig inside NewPricingClient, ignoring the credentials already resolved for the EC2 client. It also had no way to select an AWS profile, so users on AWS SSO / IAM Identity Center hit "no EC2 IMDS role found" while kubectl (which resolves its own profile via the kubeconfig exec plugin) worked fine. - Add a --profile flag (and `profile` config-file key) to select the AWS profile; falls back to the standard credential chain (AWS_PROFILE, shared config) when empty. - Drop the no-op WithSharedConfigProfile("") in main. - NewPricingClient now copies the resolved config and only overrides the region to a pricing-API-capable one, so the configured profile / SSO session is honored for pricing too. - Document the flag and the IMDS troubleshooting case in the README. Fixes #477 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 16 +++++++++++- cmd/eks-node-viewer/flag.go | 4 +++ cmd/eks-node-viewer/main.go | 10 +++++-- pkg/aws/pricing.go | 42 +++++++++++++++--------------- pkg/aws/pricing_test.go | 52 +++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 24 deletions(-) create mode 100644 pkg/aws/pricing_test.go diff --git a/README.md b/README.md index 900516b..275f331 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Usage of ./eks-node-viewer: Node label selector used to filter nodes, if empty all nodes are selected -node-sort string Sort order for the nodes, either 'creation' or a label name. The sort order can be controlled by appending =asc or =dsc to the value. (default "creation") + -profile string + AWS profile to use for pricing API calls. If empty, the standard AWS credential chain is used (AWS_PROFILE, shared config, etc.) -resources string List of comma separated resources to monitor (default "cpu") -style string @@ -69,7 +71,9 @@ eks-node-viewer --extra-labels topology.kubernetes.io/zone # Sort by CPU usage in descending order eks-node-viewer --node-sort=eks-node-viewer/node-cpu-usage=dsc # Specify a particular AWS profile and region -AWS_PROFILE=myprofile AWS_REGION=us-west-2 +AWS_PROFILE=myprofile AWS_REGION=us-west-2 eks-node-viewer +# Or pass the AWS profile directly (useful with AWS SSO / IAM Identity Center) +eks-node-viewer --profile myprofile ``` ### Computed Labels @@ -110,6 +114,16 @@ This CLI relies on AWS credentials to access pricing data if you don't use the ` See [credential provider documentation](https://docs.aws.amazon.com/sdk-for-go/api/aws/session/) for more. +#### no EC2 IMDS role found / failed to refresh cached credentials + +If pricing fails with errors like `get credentials: failed to refresh cached credentials, no EC2 IMDS role found` while `kubectl` works fine, the AWS SDK isn't resolving the same credentials your kubeconfig uses. This is common with AWS SSO / IAM Identity Center, where the active profile must be selected explicitly. Point the tool at the right profile with either: + +```shell +eks-node-viewer --profile myprofile +# or +AWS_PROFILE=myprofile eks-node-viewer +``` + #### I get an error of `creating client, exec plugin: invalid apiVersion "client.authentication.k8s.io/v1alpha1"` Updating your AWS cli to the latest version and [updating your kubeconfig](https://docs.aws.amazon.com/cli/latest/reference/eks/update-kubeconfig.html) should resolve this issue. diff --git a/cmd/eks-node-viewer/flag.go b/cmd/eks-node-viewer/flag.go index 4adf8ea..62c806b 100644 --- a/cmd/eks-node-viewer/flag.go +++ b/cmd/eks-node-viewer/flag.go @@ -42,6 +42,7 @@ func init() { type Flags struct { Context string + Profile string NodeSelector string ExtraLabels string NodeSort string @@ -68,6 +69,9 @@ func ParseFlags() (Flags, error) { contextDefault := cfg.getValue("context", "") flagSet.StringVar(&flags.Context, "context", contextDefault, "Name of the kubernetes context to use") + profileDefault := cfg.getValue("profile", "") + flagSet.StringVar(&flags.Profile, "profile", profileDefault, "AWS profile to use for pricing API calls. If empty, the standard AWS credential chain is used (AWS_PROFILE, shared config, etc.)") + nodeSelectorDefault := cfg.getValue("node-selector", "") flagSet.StringVar(&flags.NodeSelector, "node-selector", nodeSelectorDefault, "Node label selector used to filter nodes, if empty all nodes are selected ") diff --git a/cmd/eks-node-viewer/main.go b/cmd/eks-node-viewer/main.go index 6092683..ebfdab9 100644 --- a/cmd/eks-node-viewer/main.go +++ b/cmd/eks-node-viewer/main.go @@ -85,8 +85,14 @@ func main() { } if !flags.DisablePricing { - // Use AWS SDK Go v2 for configuration - cfg, err := config.LoadDefaultConfig(ctx, config.WithSharedConfigProfile("")) + // Use AWS SDK Go v2 for configuration. When --profile is set it takes + // precedence; otherwise the standard credential chain is used + // (AWS_PROFILE env var, shared config, etc.). + var loadOpts []func(*config.LoadOptions) error + if flags.Profile != "" { + loadOpts = append(loadOpts, config.WithSharedConfigProfile(flags.Profile)) + } + cfg, err := config.LoadDefaultConfig(ctx, loadOpts...) if err != nil { log.Fatalf("unable to load AWS SDK config: %s", err) } diff --git a/pkg/aws/pricing.go b/pkg/aws/pricing.go index e6558fd..21aea33 100644 --- a/pkg/aws/pricing.go +++ b/pkg/aws/pricing.go @@ -26,7 +26,6 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/pricing" @@ -105,23 +104,28 @@ func newZonalPricing(defaultPrice float64) zonalPricing { // pricingUpdatePeriod is how often we try to update our pricing information after the initial update on startup const pricingUpdatePeriod = 12 * time.Hour -// NewPricingClient returns a pricing client configured based on a particular region -func NewPricingClient(ctx context.Context, region string) (*pricing.Client, error) { - // pricing API doesn't have an endpoint in all regions - pricingAPIRegion := "us-east-1" - if strings.HasPrefix(region, "ap-") { - pricingAPIRegion = "ap-south-1" - } else if strings.HasPrefix(region, "cn-") { - pricingAPIRegion = "cn-northwest-1" - } else if strings.HasPrefix(region, "eu-") { - pricingAPIRegion = "eu-central-1" +// pricingAPIRegion maps an arbitrary AWS region to a region where the pricing +// API has an endpoint, since it isn't available in every region. +func pricingAPIRegion(region string) string { + switch { + case strings.HasPrefix(region, "ap-"): + return "ap-south-1" + case strings.HasPrefix(region, "cn-"): + return "cn-northwest-1" + case strings.HasPrefix(region, "eu-"): + return "eu-central-1" + default: + return "us-east-1" } +} - cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(pricingAPIRegion)) - if err != nil { - return nil, err - } - return pricing.NewFromConfig(cfg), nil +// NewPricingClient returns a pricing client that reuses the credentials already +// resolved in cfg (so a configured profile or SSO session is honored), and only +// overrides the region to one where the pricing API is available. +func NewPricingClient(cfg aws.Config) *pricing.Client { + pricingCfg := cfg.Copy() + pricingCfg.Region = pricingAPIRegion(cfg.Region) + return pricing.NewFromConfig(pricingCfg) } var allPrices = []map[string]map[ec2types.InstanceType]float64{ @@ -158,11 +162,7 @@ func NewPricingProvider(ctx context.Context, cfg aws.Config) nvp.Provider { } ec2Client := ec2.NewFromConfig(cfg) - pricingClient, err := NewPricingClient(ctx, region) - if err != nil { - log.Printf("Failed to create pricing client: %v", err) - pricingClient = nil - } + pricingClient := NewPricingClient(cfg) p := &pricingProvider{ region: region, diff --git a/pkg/aws/pricing_test.go b/pkg/aws/pricing_test.go new file mode 100644 index 0000000..2dc5e1d --- /dev/null +++ b/pkg/aws/pricing_test.go @@ -0,0 +1,52 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package aws + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +func TestPricingAPIRegion(t *testing.T) { + cases := map[string]string{ + "us-east-1": "us-east-1", + "us-west-2": "us-east-1", + "ap-south-1": "ap-south-1", + "ap-northeast-1": "ap-south-1", + "cn-north-1": "cn-northwest-1", + "eu-west-1": "eu-central-1", + "": "us-east-1", + } + for in, want := range cases { + if got := pricingAPIRegion(in); got != want { + t.Errorf("pricingAPIRegion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestNewPricingClientPreservesConfig(t *testing.T) { + cfg := aws.Config{Region: "eu-west-1"} + + if client := NewPricingClient(cfg); client == nil { + t.Fatal("NewPricingClient returned nil") + } + + // The pricing client must not mutate the caller's config: it should copy + // before overriding the region, leaving the original credentials/region in + // place for the EC2 client. + if cfg.Region != "eu-west-1" { + t.Errorf("NewPricingClient mutated the input config region to %q", cfg.Region) + } +}