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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions cmd/eks-node-viewer/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func init() {

type Flags struct {
Context string
Profile string
NodeSelector string
ExtraLabels string
NodeSort string
Expand All @@ -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 ")

Expand Down
10 changes: 8 additions & 2 deletions cmd/eks-node-viewer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
42 changes: 21 additions & 21 deletions pkg/aws/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions pkg/aws/pricing_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}