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) + } +}