Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- AWS provider authentication now uses the standard SDK credential chain when explicit credentials are absent, enabling EKS Pod Identity and IRSA with automatic temporary-credential refresh instead of forcing EC2 instance-role credentials.
- The NFD engine now rejects an empty generated object set when cleanup is enabled, preserving the last published topology instead of deleting every Topograph-managed NFD object after an empty provider result or over-narrow node selection.
- The NFD engine now groups nodes with `nvidia.com/gpu.clique` by that authoritative accelerator value instead of omitting their accelerator attribute.
- The NFD engine now publishes the `system.name/nodename` attribute required by NFD to populate `NodeFeatureGroup.status.nodes`, including for simulated KWOK nodes where no NFD worker executes.
Expand Down
37 changes: 24 additions & 13 deletions docs/providers/aws.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ These IDs describe a path through the three-tier network, from the leaf up to th
physical switches that share similar characteristics and connectivity patterns. Additionally, a record might include a
`CapacityBlockId`, which corresponds to the node’s NVLink domain.

Access to this API requires an IAM account with the `AmazonEC2ReadOnlyAccess` policy attached.
There are two main authentication methods:
Access to this API requires the following IAM permission:

* Providing IAM credentials directly
* Assigning an IAM role to the instance running Topograph
```json
{
"Effect": "Allow",
"Action": "ec2:DescribeInstanceTopology",
"Resource": "*"
}
```

There are two authentication modes:

1. Explicit credentials supplied through Topograph
2. AWS SDK default credential chain

## Option 1: Using Explicit Credentials

Expand All @@ -19,7 +28,9 @@ AWS credentials consist of:
* `secretAccessKey`
* (Optional) `token`

You can provide credentials in several ways:
Credentials in an API request take precedence over credentials loaded from `credentialsPath`. Both take precedence over the AWS SDK default credential chain.

You can provide explicit credentials in two ways:

### Credentials via File

Expand Down Expand Up @@ -60,17 +71,17 @@ Pass credentials directly in the topology request payload:
}
```

### Credentials via Environment Variables
## Option 2: Using the AWS SDK Default Credential Chain

Set IAM credentials as environment variables before starting the Topograph process:
When explicit credentials are not provided, Topograph uses the [AWS SDK default credential chain](https://docs.aws.amazon.com/sdk-for-go/v2/developer-guide/configure-gosdk.html). This supports environment variables, shared AWS configuration, EKS Pod Identity, IAM Roles for Service Accounts (IRSA), and the IAM role assigned to the instance running Topograph.

```sh
export AWS_ACCESS_KEY_ID=<ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<SECRET_ACCESS_KEY>
export AWS_SESSION_TOKEN=<OPTIONAL_TOKEN>
```
### EKS Pod Identity

Install the [EKS Pod Identity Agent](https://docs.aws.amazon.com/eks/latest/userguide/pod-id-agent-setup.html), then [associate an IAM role](https://docs.aws.amazon.com/eks/latest/userguide/pod-id-association.html) with the ServiceAccount used by the Topograph API server. EKS injects the container credentials endpoint and authorization token into the pod; the AWS SDK discovers and refreshes those temporary credentials automatically.

The SDK may select environment or shared-profile credentials before EKS Pod Identity or an EC2 instance role. If either role should be authoritative, make sure higher-priority sources are not configured, including `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, `AWS_PROFILE`, `~/.aws/config`, and `~/.aws/credentials`.

## Option 2: Assigning IAM Role to an Instance
### Assigning IAM Role to an Instance

Alternatively, you can assign an IAM role to the compute node running Topograph. In this case, explicit credentials are not required, as AWS automatically provides the necessary permissions.
For more information, refer to the [documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/attach-iam-role.html).
3 changes: 0 additions & 3 deletions pkg/providers/aws/imds.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"context"
"fmt"
"net/http"
"time"

"github.com/NVIDIA/topograph/internal/exec"
"github.com/NVIDIA/topograph/pkg/providers"
Expand All @@ -36,8 +35,6 @@ const (
IMDSTokenHeaderVal = "60"
IMDSTokenHeader = IMDSTokenHeaderKey + ": " + IMDSTokenHeaderVal
IMDSHeaderKey = "X-aws-ec2-metadata-token"

tokenTimeDelay = 15 * time.Second
)

func instanceToNodeMap(ctx context.Context, nodes []string) (map[string]string, error) {
Expand Down
6 changes: 6 additions & 0 deletions pkg/providers/aws/instance_topology.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ func (p *baseProvider) generateRegionInstanceTopology(ctx context.Context, pageS
return httperr.NewError(http.StatusBadGateway,
fmt.Sprintf("failed to get client: %v", err))
}
if client.credentials != nil {
if _, err := client.credentials.Retrieve(ctx); err != nil {
return httperr.NewError(http.StatusUnauthorized,
fmt.Sprintf("failed to retrieve AWS credentials: %v", err))
}
}
input := &ec2.DescribeInstanceTopologyInput{}

// AWS allows up to 100 explicitly specified instance IDs
Expand Down
99 changes: 31 additions & 68 deletions pkg/providers/aws/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,10 @@ import (
"context"
"fmt"
"net/http"
"os"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/mitchellh/mapstructure"
"k8s.io/klog/v2"
Expand All @@ -52,15 +49,12 @@ type EC2Client interface {
DescribeInstanceTopology(ctx context.Context, params *ec2.DescribeInstanceTopologyInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTopologyOutput, error)
}

type CredsClient interface {
Retrieve(ctx context.Context) (aws.Credentials, error)
}

type ClientFactory func(region string, pageSize *int) (*Client, error)

type Client struct {
ec2 EC2Client
pageSize int32
ec2 EC2Client
credentials aws.CredentialsProvider
pageSize int32
}

func (c *Client) PageSize() *int32 {
Expand All @@ -78,7 +72,7 @@ func NamedLoader() (string, providers.Loader) {
}

func Loader(ctx context.Context, cfg providers.Config) (providers.Provider, *httperr.Error) {
creds, httpErr := getCredentials(ctx, cfg.Creds)
credsProvider, httpErr := getCredentialsProvider(cfg.Creds)
if httpErr != nil {
return nil, httpErr
}
Expand All @@ -89,61 +83,49 @@ func Loader(ctx context.Context, cfg providers.Config) (providers.Provider, *htt
}

clientFactory := func(region string, pageSize *int) (*Client, error) {
opts := []func(*config.LoadOptions) error{
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(creds.AccessKeyId, creds.SecretAccessKey, creds.Token),
)}

awsCfg, err := config.LoadDefaultConfig(ctx, opts...)
awsCfg, err := loadAWSConfig(ctx, region, credsProvider)
if err != nil {
return nil, fmt.Errorf("failed to load SDK config: %v", err)
}

ec2Client := ec2.NewFromConfig(awsCfg)

return &Client{
ec2: ec2Client,
pageSize: setPageSize(pageSize),
ec2: ec2Client,
credentials: awsCfg.Credentials,
pageSize: setPageSize(pageSize),
}, nil
}

return New(clientFactory, trimTiers), nil
}

func getCredentials(ctx context.Context, creds map[string]any) (*Credentials, *httperr.Error) {
var accessKeyID, secretAccessKey, sessionToken string
func getCredentialsProvider(creds map[string]any) (aws.CredentialsProvider, *httperr.Error) {
if len(creds) == 0 {
klog.Infof("Using AWS SDK default credential chain")
return nil, nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With no explicit creds, credential-resolution failure is now deferred to the API call: the old ec2rolecreds path returned 401 at load time when instance-role creds couldn't be retrieved, whereas an unresolvable default chain now surfaces as the 502 from DescribeInstanceTopology. #177 deliberately curated these status codes, so worth deciding whether a no-credentials failure should still present as 401 rather than 502.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The status-code change was not intentional. I’ll preserve the previous 401 for credential-resolution failures while keeping EC2 API authorization errors as 502.

}

if len(creds) != 0 {
klog.Infof("Using provided AWS credentials")
parsedCreds, err := decodeCredentials(creds)
if err != nil {
return nil, httperr.NewError(http.StatusBadRequest, "credentials error: "+err.Error())
}
accessKeyID = parsedCreds.AccessKeyId
secretAccessKey = parsedCreds.SecretAccessKey
sessionToken = parsedCreds.Token
} else if len(os.Getenv("AWS_ACCESS_KEY_ID")) != 0 && len(os.Getenv("AWS_SECRET_ACCESS_KEY")) != 0 {
klog.Infof("Using shell AWS credentials")
accessKeyID = os.Getenv("AWS_ACCESS_KEY_ID")
secretAccessKey = os.Getenv("AWS_SECRET_ACCESS_KEY")
sessionToken = os.Getenv("AWS_SESSION_TOKEN")
} else {
klog.Infof("Using node AWS access credentials")
creds, err := getCredentialsFromProvider(ctx)
if err != nil {
return nil, httperr.NewError(http.StatusUnauthorized, err.Error())
}
accessKeyID = creds.AccessKeyID
secretAccessKey = creds.SecretAccessKey
sessionToken = creds.SessionToken
klog.Infof("Using explicit Topograph AWS credentials")
parsedCreds, err := decodeCredentials(creds)
if err != nil {
return nil, httperr.NewError(http.StatusBadRequest, "credentials error: "+err.Error())
}

return credentials.NewStaticCredentialsProvider(
parsedCreds.AccessKeyId,
parsedCreds.SecretAccessKey,
parsedCreds.Token,
), nil
}

func loadAWSConfig(ctx context.Context, region string, credsProvider aws.CredentialsProvider) (aws.Config, error) {
opts := []func(*config.LoadOptions) error{config.WithRegion(region)}
if credsProvider != nil {
opts = append(opts, config.WithCredentialsProvider(credsProvider))
}

return &Credentials{
AccessKeyId: accessKeyID,
SecretAccessKey: secretAccessKey,
Token: sessionToken,
}, nil
return config.LoadDefaultConfig(ctx, opts...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One precedence change worth confirming is intentional: the old resolver only consulted request creds, then AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, then the EC2 instance role — it never read ~/.aws/config, ~/.aws/credentials, or AWS_PROFILE. The full default chain places shared-config/profile (and IRSA) ahead of the instance role, so a host with stray ~/.aws state or an exported AWS_PROFILE that previously fell through to instance-role creds will now silently authenticate as that profile identity. The docs cover the env / Pod Identity case; worth extending the caveat to shared-config/profile too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the expanded precedence is intentional because the goal is to use the standard SDK chain. I’ll make the warning explicit for AWS_PROFILE and shared config/credentials files.

}

func decodeCredentials(creds map[string]any) (*Credentials, error) {
Expand All @@ -161,25 +143,6 @@ func decodeCredentials(creds map[string]any) (*Credentials, error) {
return c, nil
}

func getCredentialsFromProvider(ctx context.Context) (creds aws.Credentials, err error) {
credsClient := ec2rolecreds.New()

for {
creds, err = credsClient.Retrieve(ctx)
if err != nil {
return creds, err
}

if time.Now().Add(tokenTimeDelay).After(creds.Expires) {
klog.V(4).Infof("Waiting %s for new token", tokenTimeDelay.String())
time.Sleep(tokenTimeDelay)
continue
}

return creds, nil
}
}

func (p *baseProvider) GenerateTopologyConfig(ctx context.Context, pageSize *int, instances []topology.ComputeInstances) (*topology.Graph, *httperr.Error) {
topo, err := p.generateInstanceTopology(ctx, pageSize, instances)
if err != nil {
Expand Down
Loading