Skip to content

Commit db4aa0f

Browse files
[delegatedauth] Support all AWS credential sources in every Agent flavor | WIF-75 (#54318)
### What does this PR do? Removes the `ec2` build tag from Agent Cloud Auth's AWS credential resolution, so every Agent flavor can use EKS IRSA, ECS task roles, EKS Pod Identity and EC2 IMDS. Newly covered: trace-agent, standalone Dogstatsd, private action runner, IoT, Heroku. `ec2` still gates DBM and EC2 host tagging, untouched. ### Why `ec2` also gates DBM and EC2 host tagging, so flavors that opted out of those silently lost Cloud Auth with them. Most importantly APM has never supported it, and `comp/trace/config/fx` sets `FailIfAPIKeyMissing: true`, so a customer using Cloud Auth for APM can't drop the static `api_key`. ### What changed **Build-tag removal.** The three `!ec2` stubs are deleted and the tag dropped from credential resolution and detection. `pkg/util/aws/creds` had no other consumer. No replacement tag: gating cost +56 KiB per excluded flavor, and gating is what created the silent gap to begin with. **Two SDK providers replaced.** IRSA and IMDS were already resolved by our own code. The remaining two SDK dependencies are gone: `credentials/endpointcreds` for the ECS/EKS container leg (replaced by a GET + JSON decode) and `credentials.NewStaticCredentialsProvider` (a 2-line struct). `aws-sdk-go-v2/aws` and `signer/v4` stay, both already linked for the SigV4 proof. No change to the STS or SigV4 paths. The container replacement keeps the retry behavior `endpointcreds` provided: connection errors, truncated bodies, 5xx and 429 are retried, bounded by an attempt cap and an elapsed budget, while a 403 or a malformed document is not. IRSA and IMDS have no inner retries, unchanged, so the three legs are intentionally not uniform. **Diagnosability.** Detection returns why it failed rather than a bool, failures name the one mechanism that was tried and what to check for it, and blank-but-error-free credentials are now a failure rather than a false "resolved". A half-configured credential pair (ex: `AWS_ACCESS_KEY_ID` without `AWS_SECRET_ACCESS_KEY`) logs a warning naming it, and is still skipped rather than erroring, matching the AWS SDK. `agent status` reports disable reason, per-key credential source, last and next refresh, and last error. **Config precedence fix.** Setting only `delegated_auth.aws.region` built a non-nil `ProviderConfig`, which downstream reads as "explicitly configured" and skipped provider detection entirely. The region is now applied to the auto-detected provider. **`cloud_provider_metadata` opt-out.** Three IMDS entry points reached `DoHTTPRequest` directly rather than through `GetMetadataItem`, so an operator who excluded `aws` was probed anyway. All three now check first. This predates the PR, but removing the build tag is what exposes it to flavors that otherwise never touch IMDS. ### Size | Build | vs `main` | |---|---| | trace-agent binary | +73.2 KiB | | `iot_agent_deb_amd64` package | +4.03 KiB | | `agent_rpm_arm64` package | +55.97 KiB | Static quality gates moved +0.06 MiB on `agent_rpm_arm64` and `agent_suse_arm64`, and nowhere else. Keeping `endpointcreds` would have cost +404.8 KiB instead of +73.2 KiB on the trace-agent, almost all of it smithy-go middleware and its reflection metadata, and would have put IoT over its hard limit. The main Agent package grows even though its binary already had this code: `omnibus/config/software/datadog-agent.rb` ships `trace-agent` and `privateactionrunner` inside it, and those two are what newly compile the credential code. ### Deliberately not in this PR - `otel-agent` (DDOT) still uses the noop delegated-auth module. - Adding inner retries to the IRSA and IMDS legs. ### Describe how you validated your changes **Staging end to end** on `charcadet.us1.staging.dog`, two deploys differing only in the agent image. All four flavors on the cluster (`agent`, `trace-agent`, `process-agent`, `private-action-runner`) go from `missing AWS credentials` to a delegated key, verified across 16 pods and 48 containers. Per-phase log links and reasoning in [this comment](#54318 (comment)). IRSA and static-env credentials on real AWS are not covered end to end and remain for `qa/rc-required`. **Local** (`trace-agent`, default flavor tags, no `ec2`): container-credentials path reached end to end against a stub endpoint. A pre-PR non-`ec2` build stops in the `_noec2` stub and cannot get this far. **Tests.** `bzl test //comp/core/delegatedauth/...:all //pkg/util/aws/creds:all` passes, including flavor targets that never compiled this code before. New coverage for the container provider (each retry class asserts exact request counts, so the non-retry cases cannot silently start retrying), region precedence, detection-failure recording, status fields, and the metadata opt-out. Co-authored-by: stephen.rosenthal <stephen.rosenthal@datadoghq.com>
1 parent dbd7f40 commit db4aa0f

33 files changed

Lines changed: 1662 additions & 595 deletions

comp/core/delegatedauth/api/cloudauth/aws/BUILD.bazel

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ go_library(
55
name = "aws",
66
srcs = [
77
"aws.go",
8-
"resolve_credentials_ec2.go",
9-
"resolve_credentials_noec2.go",
8+
"resolve_credentials.go",
109
],
1110
importpath = "github.com/DataDog/datadog-agent/comp/core/delegatedauth/api/cloudauth/aws",
1211
visibility = ["//visibility:public"],
@@ -20,26 +19,20 @@ go_library(
2019
"//pkg/version",
2120
"@com_github_aws_aws_sdk_go_v2//aws",
2221
"@com_github_aws_aws_sdk_go_v2//aws/signer/v4:signer",
23-
"@com_github_aws_aws_sdk_go_v2_credentials//:credentials",
24-
"@com_github_aws_aws_sdk_go_v2_credentials//endpointcreds",
2522
],
2623
)
2724

2825
dd_agent_go_test(
2926
name = "aws_test",
3027
srcs = [
3128
"aws_test.go",
32-
"resolve_credentials_ec2_test.go",
33-
"resolve_credentials_noec2_test.go",
29+
"resolve_credentials_providers_test.go",
3430
"resolve_credentials_test.go",
3531
],
3632
embed = [":aws"],
37-
gotags_sets = [["ec2"]],
3833
deps = [
3934
"//pkg/config/mock",
4035
"//pkg/util/aws/creds",
41-
"@com_github_aws_aws_sdk_go_v2_credentials//:credentials",
42-
"@com_github_aws_aws_sdk_go_v2_credentials//endpointcreds",
4336
"@com_github_stretchr_testify//assert",
4437
"@com_github_stretchr_testify//require",
4538
],

comp/core/delegatedauth/api/cloudauth/aws/aws.go

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"errors"
1717
"fmt"
1818
"net/http"
19+
"sync/atomic"
1920
"time"
2021

2122
cloudauthconfig "github.com/DataDog/datadog-agent/comp/core/delegatedauth/api/cloudauth/config"
@@ -25,7 +26,6 @@ import (
2526

2627
pkgconfigmodel "github.com/DataDog/datadog-agent/pkg/config/model"
2728
"github.com/DataDog/datadog-agent/pkg/util/aws/creds"
28-
"github.com/DataDog/datadog-agent/pkg/util/log"
2929
"github.com/DataDog/datadog-agent/pkg/version"
3030
)
3131

@@ -53,6 +53,13 @@ const (
5353
// AWSAuth contains the implementation for the AWS cloud auth
5454
type AWSAuth struct {
5555
region string
56+
57+
// lastSource names the credential mechanism selected by the most recent resolveCredentials
58+
// attempt (ex: "DelegatedAuthIMDS"). It is written before the credentials are fetched, so it
59+
// reflects what was attempted rather than what succeeded; see CredentialSourceReporter. Read by
60+
// the status page via common.CredentialSourceReporter. Credentials are resolved on the
61+
// delegated-auth refresh goroutine and read by whoever renders status, so it is atomic.
62+
lastSource atomic.Pointer[string]
5663
}
5764

5865
// NewAWSAuth creates a new AWSAuth from an AWSProviderConfig.
@@ -66,6 +73,20 @@ func NewAWSAuth(config *cloudauthconfig.AWSProviderConfig) *AWSAuth {
6673
}
6774
}
6875

76+
// Compile-time check that the status page's optional interface stays satisfied. Without it a rename
77+
// would silently drop the credential source from `agent status` rather than fail the build.
78+
var _ common.CredentialSourceReporter = (*AWSAuth)(nil)
79+
80+
// LastCredentialSource implements common.CredentialSourceReporter. It names the mechanism selected
81+
// for the most recent resolution attempt, whether or not that attempt succeeded, so it is populated
82+
// exactly when an operator needs it most.
83+
func (a *AWSAuth) LastCredentialSource() string {
84+
if s := a.lastSource.Load(); s != nil {
85+
return *s
86+
}
87+
return ""
88+
}
89+
6990
// GenerateAuthProof generates an AWS-specific authentication proof using SigV4 signing.
7091
// This proof includes a signed AWS STS GetCallerIdentity request that proves access to AWS credentials.
7192
// The context parameter allows for cancellation of the proof generation.
@@ -75,14 +96,20 @@ func (a *AWSAuth) GenerateAuthProof(ctx context.Context, cfg pkgconfigmodel.Read
7596
return "", ctx.Err()
7697
}
7798

78-
// Get local AWS Credentials. cfg is threaded through so the IRSA web-identity STS call can use
79-
// the Agent's configured HTTP transport (proxy / custom CA / TLS settings).
80-
credentials := a.getCredentials(ctx, cfg)
81-
8299
if config == nil || config.OrgUUID == "" {
83100
return "", errors.New("missing org UUID in config")
84101
}
85102

103+
// Get local AWS Credentials. cfg is threaded through so the IRSA web-identity STS call can use
104+
// the Agent's configured HTTP transport (proxy / custom CA / TLS settings). The error names the
105+
// credential mechanism that was tried and why it failed, and is returned rather than only logged
106+
// so it reaches the caller's error log and the status page instead of a generic
107+
// "missing AWS credentials".
108+
credentials, err := a.getCredentials(ctx, cfg)
109+
if err != nil {
110+
return "", err
111+
}
112+
86113
// Use the credentials to generate the signing data
87114
data, err := a.generateAwsAuthData(ctx, config.OrgUUID, credentials)
88115
if err != nil {
@@ -99,18 +126,56 @@ func (a *AWSAuth) GenerateAuthProof(ctx context.Context, cfg pkgconfigmodel.Read
99126
return authProof, nil
100127
}
101128

102-
// getCredentials retrieves AWS credentials using the build-tag-selected chain:
103-
// - ec2 build: AWS SDK chain limited to env -> web identity -> container -> IMDS (shared config/SSO/profiles disabled)
104-
// - non-ec2 build: static env vars only (AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
105-
func (a *AWSAuth) getCredentials(ctx context.Context, cfg pkgconfigmodel.Reader) *creds.SecurityCredentials {
106-
resolved := a.resolveCredentials(ctx, cfg)
107-
if resolved == nil {
108-
return &creds.SecurityCredentials{}
129+
// getCredentials retrieves AWS credentials from the environment the Agent is running in, using the
130+
// one mechanism that matches it: env, IRSA web identity, ECS/EKS container credentials, or IMDS
131+
// (shared config/SSO/profiles are deliberately unsupported, see resolveCredentials).
132+
//
133+
// A failure here means delegated auth cannot fetch an API key at all, so the error is annotated
134+
// with what the operator should look at for the mechanism that was actually tried, then returned.
135+
// Nothing is logged here: the caller already reports the failure, and logging as well would emit
136+
// the same remediation text three times per attempt.
137+
func (a *AWSAuth) getCredentials(ctx context.Context, cfg pkgconfigmodel.Reader) (*creds.SecurityCredentials, error) {
138+
resolved, err := a.resolveCredentials(ctx, cfg)
139+
if err != nil {
140+
// A canceled context means the Agent is shutting down or this instance was replaced, not that
141+
// the environment is misconfigured. Return it unadorned so callers can recognize it and skip
142+
// the remediation hint.
143+
if ctx.Err() != nil {
144+
return nil, err
145+
}
146+
return nil, fmt.Errorf("%w. %s", err, credentialRemediation(a.LastCredentialSource(), cfg))
109147
}
110-
if resolved.AccessKeyID == "" || resolved.SecretAccessKey == "" {
111-
log.Debugf("AWS credential resolution returned empty credentials")
148+
return resolved, nil
149+
}
150+
151+
// credentialRemediation returns the check to suggest for the credential mechanism that was tried.
152+
// Naming only that mechanism matters because the chain is first-match: telling an IRSA pod to
153+
// verify IMDS reachability sends the operator down a path the Agent never took.
154+
func credentialRemediation(source string, cfg pkgconfigmodel.Reader) string {
155+
switch source {
156+
case creds.SourceEnvironment:
157+
return "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set, so no other credential source was tried; check that they are valid and not expired"
158+
case creds.SourceWebIdentity:
159+
return "AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE are set, so IRSA was used; check that the token file is mounted and readable and that the role's trust policy allows this service account"
160+
case creds.SourceContainer:
161+
return "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or AWS_CONTAINER_CREDENTIALS_FULL_URI is set, so ECS/EKS container credentials were used; check that the task role or Pod Identity association exists and that the credential endpoint is reachable"
162+
case creds.SourceIMDS:
163+
// Report the IMDS versions actually attempted rather than ec2_prefer_imdsv2 alone. The IMDS
164+
// helper allows v2 when either ec2_prefer_imdsv2 or ec2_imdsv2_transition_payload_enabled is
165+
// set (UseIMDSv2 in pkg/util/aws/creds/internal), and the latter defaults to true, so naming
166+
// only the first key tells a default-configured operator that v2 was not tried when it was.
167+
imdsVersions := "v1 only"
168+
if cfg.GetBool("ec2_prefer_imdsv2") || cfg.GetBool("ec2_imdsv2_transition_payload_enabled") {
169+
imdsVersions = "v2 then v1"
170+
}
171+
return fmt.Sprintf("no credential environment variables were set, so EC2 IMDS was used; check that an instance profile is attached and that IMDS is reachable (ec2_metadata_timeout=%dms, IMDS versions attempted=%s)",
172+
cfg.GetInt("ec2_metadata_timeout"), imdsVersions)
173+
default:
174+
// The source is unknown only if selection itself failed before recording one. Say nothing
175+
// specific rather than defaulting to IMDS advice, which would misdirect exactly the way the
176+
// old catch-all message did.
177+
return "the credential mechanism could not be determined"
112178
}
113-
return resolved
114179
}
115180

116181
func (a *AWSAuth) getConnectionParameters() (string, string, string) {

comp/core/delegatedauth/api/cloudauth/aws/aws_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import (
99
"context"
1010
"encoding/base64"
1111
"encoding/json"
12+
"fmt"
1213
"testing"
1314

1415
"github.com/stretchr/testify/assert"
1516
"github.com/stretchr/testify/require"
1617

18+
configmock "github.com/DataDog/datadog-agent/pkg/config/mock"
1719
"github.com/DataDog/datadog-agent/pkg/util/aws/creds"
1820
)
1921

@@ -217,3 +219,51 @@ func TestGenerateAwsAuthDataWithoutToken(t *testing.T) {
217219
// X-Amz-Security-Token should NOT be present for permanent credentials
218220
assert.NotContains(t, headers, "X-Amz-Security-Token")
219221
}
222+
223+
func TestCredentialRemediationIMDSVersions(t *testing.T) {
224+
// The IMDS leg allows v2 when either ec2_prefer_imdsv2 or
225+
// ec2_imdsv2_transition_payload_enabled is set (UseIMDSv2 in pkg/util/aws/creds/internal), and
226+
// the latter defaults to true. Reporting ec2_prefer_imdsv2 alone told an operator running the
227+
// default configuration that v2 was not attempted when it was.
228+
tests := []struct {
229+
name string
230+
preferV2 bool
231+
transition bool
232+
want string
233+
}{
234+
{name: "defaults attempt v2", preferV2: false, transition: true, want: "v2 then v1"},
235+
{name: "prefer_imdsv2 alone", preferV2: true, transition: false, want: "v2 then v1"},
236+
{name: "both set", preferV2: true, transition: true, want: "v2 then v1"},
237+
{name: "neither set is v1 only", preferV2: false, transition: false, want: "v1 only"},
238+
}
239+
for _, tc := range tests {
240+
t.Run(tc.name, func(t *testing.T) {
241+
cfg := configmock.NewFromYAML(t, fmt.Sprintf(
242+
"ec2_prefer_imdsv2: %t\nec2_imdsv2_transition_payload_enabled: %t\n",
243+
tc.preferV2, tc.transition))
244+
245+
got := credentialRemediation(creds.SourceIMDS, cfg)
246+
247+
assert.Contains(t, got, "IMDS versions attempted="+tc.want)
248+
// The old key must not reappear: it is the config value, not the effective behavior.
249+
assert.NotContains(t, got, "ec2_prefer_imdsv2=")
250+
})
251+
}
252+
}
253+
254+
func TestCredentialRemediationNamesOnlyTheAttemptedSource(t *testing.T) {
255+
// The chain is first-match, so remediation must describe one mechanism. Advising an IRSA pod to
256+
// check IMDS reachability sends the operator down a path the Agent never took.
257+
cfg := configmock.New(t)
258+
259+
assert.Contains(t, credentialRemediation(creds.SourceWebIdentity, cfg), "IRSA was used")
260+
assert.NotContains(t, credentialRemediation(creds.SourceWebIdentity, cfg), "IMDS")
261+
262+
assert.Contains(t, credentialRemediation(creds.SourceContainer, cfg), "container credentials were used")
263+
assert.NotContains(t, credentialRemediation(creds.SourceContainer, cfg), "IMDS")
264+
265+
assert.Contains(t, credentialRemediation(creds.SourceEnvironment, cfg), "no other credential source was tried")
266+
267+
// An unrecognized source must not fall back to IMDS advice.
268+
assert.Equal(t, "the credential mechanism could not be determined", credentialRemediation("", cfg))
269+
}

comp/core/delegatedauth/api/cloudauth/aws/go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ require (
1111
github.com/DataDog/datadog-agent/pkg/util/log v0.73.0-rc.5
1212
github.com/DataDog/datadog-agent/pkg/version v0.72.2
1313
github.com/aws/aws-sdk-go-v2 v1.43.3
14-
github.com/aws/aws-sdk-go-v2/credentials v1.19.33
1514
github.com/stretchr/testify v1.11.1
1615
)
1716

comp/core/delegatedauth/api/cloudauth/aws/go.sum

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)