lib/awsapi: add support for named AWS profile to ec2_sd_config

Add support for named AWS profiles in ec2_sd_config, matching Prometheus behavior.

Example:

```text
~/.aws/config:
[profile account-one]
source_profile = root
role_arn = arn:aws:iam::000000000001:role/prometheus
```

```yaml
scrape config:
- job: ec2
  ec2_sd_configs:
    - profile: account-one
```

Fixes https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1685
This commit is contained in:
andriibeee
2026-04-09 12:17:17 +03:00
committed by GitHub
parent 0e9870b7a9
commit 0aaa741b5b
7 changed files with 327 additions and 10 deletions

View File

@@ -290,7 +290,7 @@ func getAWSAPIConfig(argIdx int) (*awsapi.Config, error) {
accessKey := awsAccessKey.GetOptionalArg(argIdx)
secretKey := awsSecretKey.GetOptionalArg(argIdx)
service := awsService.GetOptionalArg(argIdx)
cfg, err := awsapi.NewConfig(ec2Endpoint, stsEndpoint, region, roleARN, accessKey, secretKey, service)
cfg, err := awsapi.NewConfig(ec2Endpoint, stsEndpoint, region, roleARN, accessKey, secretKey, service, "")
if err != nil {
return nil, err
}

View File

@@ -73,6 +73,7 @@ Released at 2026-03-13
* SECURITY: upgrade Go builder from Go1.26.0 to Go1.26.1. See [the list of issues addressed in Go1.26.1](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1%20label%3ACherryPickApproved).
* FEATURE: [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/): add `profile` option to `ec2_sd_configs` for loading credentials from named AWS profiles in `~/.aws/credentials` and `~/.aws/config`, including `source_profile` chaining and `role_arn` resolution. See [ec2_sd_configs docs](https://docs.victoriametrics.com/victoriametrics/sd_configs/#ec2_sd_configs). Issue [#1685](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1685).
* FEATURE: [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/): add `headers` field to `oauth2` scrape config for passing custom HTTP headers to `token_url`. Some services require different headers for the token endpoint and the scrape targets. See [#8939](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8939).
* FEATURE: [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/): add [OIDC Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) support for JWT authentication. `vmauth` can now automatically fetch and rotate public keys from an OpenID Connect provider, eliminating the need to specify public keys manually. See [OIDC Discovery](https://docs.victoriametrics.com/victoriametrics/vmauth/#oidc-discovery) docs. See [#10585](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10585).
* FEATURE: all VictoriaMetrics components: implement proper CORS preflight handling by responding 204 No Content to HTTP OPTIONS requests. See [#5563](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5563).

View File

@@ -648,6 +648,12 @@ scrape_configs:
#
# role_arn: "..."
# profile is an optional named AWS profile from ~/.aws/config and ~/.aws/credentials.
# When set, credentials and role_arn are resolved from the profile, with source_profile
# chaining supported. See https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html
#
# profile: "..."
# port is an optional port to scrape metrics from.
# By default, port 80 is used.
#

View File

@@ -1,6 +1,7 @@
package awsapi
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
@@ -8,6 +9,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -37,6 +39,9 @@ type Config struct {
defaultAccessKey string
defaultSecretKey string
// profile is the named AWS profile to use from shared credentials/config files.
profile string
// Real credentials used for accessing EC2 API.
creds *credentials
credsLock sync.Mutex
@@ -51,7 +56,7 @@ type credentials struct {
}
// NewConfig returns new AWS Config from the given args.
func NewConfig(ec2Endpoint, stsEndpoint, region, roleARN, accessKey, secretKey, service string) (*Config, error) {
func NewConfig(ec2Endpoint, stsEndpoint, region, roleARN, accessKey, secretKey, service, profile string) (*Config, error) {
cfg := &Config{
client: http.DefaultClient,
region: region,
@@ -61,6 +66,7 @@ func NewConfig(ec2Endpoint, stsEndpoint, region, roleARN, accessKey, secretKey,
service: service,
defaultAccessKey: os.Getenv("AWS_ACCESS_KEY_ID"),
defaultSecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
profile: profile,
}
if cfg.service == "" {
cfg.service = "aps"
@@ -184,8 +190,8 @@ func (cfg *Config) getFreshAPICredentials() (*credentials, error) {
// There is no need in refreshing statically set api credentials if roleARN isn't set.
return cfg.creds, nil
}
if time.Until(cfg.creds.Expiration) > 10*time.Second {
// credentials aren't expired yet.
if cfg.creds != nil && (cfg.creds.Expiration.IsZero() || time.Until(cfg.creds.Expiration) > 10*time.Second) {
// credentials aren't expired yet; zero expiration means they don't expire (e.g. static profile keys).
return cfg.creds, nil
}
// credentials have been expired. Update them.
@@ -207,6 +213,8 @@ func (cfg *Config) getAPICredentials() (*credentials, error) {
if relativeURI := os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"); len(relativeURI) > 0 {
fullURI = "http://169.254.170.2" + relativeURI
}
// roleARN may be overridden by profile's role_arn config entry.
roleARN := cfg.roleARN
switch {
case len(acNew.AccessKeyID) > 0 && len(acNew.SecretAccessKey) > 0:
case len(cfg.webTokenPath) > 0:
@@ -229,6 +237,29 @@ func (cfg *Config) getAPICredentials() (*credentials, error) {
return nil, err
}
acNew = ac
case len(cfg.profile) > 0:
sourceProfile, profileRoleARN, err := readAWSConfigFile(cfg.profile)
if err != nil {
return nil, fmt.Errorf("cannot read config file for profile %q: %w", cfg.profile, err)
}
credProfile := cfg.profile
if sourceProfile != "" {
if sourceProfile == cfg.profile {
return nil, fmt.Errorf("source_profile for %q points to itself", cfg.profile)
}
credProfile = sourceProfile
}
if roleARN == "" {
roleARN = profileRoleARN
}
ac, err := readSharedCredentials(credProfile)
if err != nil {
return nil, fmt.Errorf("cannot read shared credentials for profile %q: %w", credProfile, err)
}
if ac == nil {
return nil, fmt.Errorf("missing credentials for profile %q", credProfile)
}
acNew = ac
default:
// we need instance credentials if we do not have access keys
ac, err := getInstanceRoleCredentials(cfg.client)
@@ -238,10 +269,10 @@ func (cfg *Config) getAPICredentials() (*credentials, error) {
acNew = ac
}
// read credentials from sts api, if role_arn is defined
if len(cfg.roleARN) > 0 {
ac, err := cfg.getRoleARNCredentials(acNew, cfg.roleARN)
if len(roleARN) > 0 {
ac, err := cfg.getRoleARNCredentials(acNew, roleARN)
if err != nil {
return nil, fmt.Errorf("cannot get credentials for role_arn %q: %w", cfg.roleARN, err)
return nil, fmt.Errorf("cannot get credentials for role_arn %q: %w", roleARN, err)
}
acNew = ac
}
@@ -254,6 +285,112 @@ func (cfg *Config) getAPICredentials() (*credentials, error) {
return acNew, nil
}
// readSharedCredentials reads credentials from ~/.aws/credentials for the given profile.
func readSharedCredentials(profile string) (*credentials, error) {
path := os.Getenv("AWS_SHARED_CREDENTIALS_FILE")
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("cannot get home directory: %w", err)
}
path = filepath.Join(home, ".aws", "credentials")
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("cannot read %q: %w", path, err)
}
section := readSection(data, profile)
if section == nil {
return nil, nil
}
accessKey := section["aws_access_key_id"]
secretKey := section["aws_secret_access_key"]
if accessKey == "" || secretKey == "" {
return nil, fmt.Errorf("missing aws_access_key_id or aws_secret_access_key for profile %q in %q", profile, path)
}
return &credentials{
AccessKeyID: accessKey,
SecretAccessKey: secretKey,
Token: section["aws_session_token"],
}, nil
}
// readAWSConfigFile returns source_profile and role_arn for the given profile from ~/.aws/config.
func readAWSConfigFile(profile string) (sourceProfile, roleARN string, err error) {
path := os.Getenv("AWS_CONFIG_FILE")
if path == "" {
tilde, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("cannot get home directory: %w", err)
}
path = filepath.Join(tilde, ".aws", "config")
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", "", nil
}
return "", "", fmt.Errorf("cannot read %q: %w", path, err)
}
// named profiles use "profile " prefix in config file; "default" is the exception
sectionName := "profile " + profile
if profile == "default" {
sectionName = "default"
}
section := readSection(data, sectionName)
if section == nil {
return "", "", nil
}
return section["source_profile"], section["role_arn"], nil
}
// readSection returns key-value pairs for the given section from AWS config/credentials file data.
func readSection(data []byte, section string) map[string]string {
sectionBytes := []byte(strings.TrimSpace(section))
var result map[string]string
inSection := false
for len(data) > 0 {
var line []byte
if i := bytes.IndexByte(data, '\n'); i >= 0 {
line, data = data[:i], data[i+1:]
} else {
line, data = data, nil
}
line = bytes.TrimSpace(line)
// '#' is the only recognized comment character. See https://stackoverflow.com/questions/43217469/how-do-you-comment-out-lines-in-aws-cli-config-and-credentials-files
if len(line) == 0 || line[0] == '#' {
continue
}
if line[0] == '[' {
end := bytes.IndexByte(line, ']')
if end < 0 {
continue
}
// strip double quotes to handle profile names with spaces, e.g. [profile "my profile"]
header := bytes.ReplaceAll(bytes.TrimSpace(line[1:end]), []byte{'"'}, nil)
inSection = bytes.EqualFold(header, sectionBytes)
continue
}
if !inSection {
continue
}
eq := bytes.IndexByte(line, '=')
if eq < 0 {
continue
}
key := string(bytes.TrimSpace(line[:eq]))
value := string(bytes.TrimSpace(line[eq+1:]))
if result == nil {
result = make(map[string]string)
}
result[key] = value
}
return result
}
// getCredentialsByPath makes request to metadata service and retrieves container credentials
// https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html
func getCredentialsByPath(client *http.Client, uri, token string) (*credentials, error) {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
@@ -296,6 +297,178 @@ func TestParseARNCredentialsSuccess(t *testing.T) {
f(s2, "AssumeRoleWithWebIdentity", credsExpected2)
}
func TestReadSection(t *testing.T) {
f := func(data, section string, expectedResult map[string]string) {
t.Helper()
result := readSection([]byte(data), section)
if !reflect.DeepEqual(result, expectedResult) {
t.Fatalf("unexpected result for section %q;\ngot\n%v\nwant\n%v", section, result, expectedResult)
}
}
// missing section
f("[foo]\nkey=val\n", "spoon", nil)
// happy path
f("[default]\naws_access_key_id = HESOYAM\naws_secret_access_key = BAGUVIX\n", " default ", map[string]string{
"aws_access_key_id": "HESOYAM",
"aws_secret_access_key": "BAGUVIX",
})
// comments and blank lines are skipped
f("# some comment\n[default]\n\npipeline = green\ntests = well written and stable", "default", map[string]string{
"pipeline": "green",
"tests": "well written and stable",
})
// profile prefix used in config file
f("[profile account-one]\nsource_profile = root\nrole_arn = arn:aws:iam::000000000001:role/prometheus\n", "profile account-one", map[string]string{
"source_profile": "root",
"role_arn": "arn:aws:iam::000000000001:role/prometheus",
})
// multiple sections - only the matching one is returned
f("[default]\nregion=us-east-1\n[profile foo]\nrole_arn=arn:foo\n", "profile foo", map[string]string{
"role_arn": "arn:foo",
})
// quirky line endings just in case
f("[test]\r\nfoo=bar\r\nbeep=boop\r\n", "test", map[string]string{
"foo": "bar",
"beep": "boop",
})
}
func TestReadAWSConfigFile(t *testing.T) {
f := func(content, profile, wantSourceProfile, wantRoleARN string) {
t.Helper()
tempDir := t.TempDir()
cfgPath := filepath.Join(tempDir, "config")
if err := os.WriteFile(cfgPath, []byte(content), 0600); err != nil {
t.Fatalf("cannot write config file: %v", err)
}
t.Setenv("AWS_CONFIG_FILE", cfgPath)
sourceProfile, roleARN, err := readAWSConfigFile(profile)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sourceProfile != wantSourceProfile {
t.Fatalf("unexpected source_profile; got %q, want %q", sourceProfile, wantSourceProfile)
}
if roleARN != wantRoleARN {
t.Fatalf("unexpected role_arn; got %q, want %q", roleARN, wantRoleARN)
}
}
// profile with source_profile and role_arn
f("[profile account-one]\nsource_profile = root\nrole_arn = arn:aws:iam::111:role/r\n",
"account-one", "root", "arn:aws:iam::111:role/r")
// default profile
f("[default]\nrole_arn = arn:aws:iam::222:role/r\n",
"default", "", "arn:aws:iam::222:role/r")
// profile not found returns empty strings
f("[profile other]\nrole_arn = arn:foo\n",
"missing", "", "")
}
func TestReadSharedCredentials(t *testing.T) {
f := func(content, profile string, wantCreds *credentials) {
t.Helper()
tempDir := t.TempDir()
credsPath := filepath.Join(tempDir, "credentials")
if err := os.WriteFile(credsPath, []byte(content), 0600); err != nil {
t.Fatalf("cannot write credentials file: %v", err)
}
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credsPath)
creds, err := readSharedCredentials(profile)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(creds, wantCreds) {
t.Fatalf("unexpected creds;\ngot\n%+v\nwant\n%+v", creds, wantCreds)
}
}
// basic credentials
f("[root]\naws_access_key_id = AKID\naws_secret_access_key = SECRET\n", "root", &credentials{
AccessKeyID: "AKID",
SecretAccessKey: "SECRET",
})
// credentials with session token
f("[root]\naws_access_key_id = AKID\naws_secret_access_key = SECRET\naws_session_token = TOKEN\n", "root", &credentials{
AccessKeyID: "AKID",
SecretAccessKey: "SECRET",
Token: "TOKEN",
})
// profile not found
f("[other]\naws_access_key_id = AKID\naws_secret_access_key = SECRET\n", "missing", nil)
}
func TestGetAPICredentialsWithProfile(t *testing.T) {
responses := map[string]string{
"AssumeRole": `
<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
<AssumeRoleResult>
<Credentials>
<AccessKeyId>PROFILEROLEID</AccessKeyId>
<SecretAccessKey>PROFILEROLESECRET</SecretAccessKey>
<SessionToken>PROFILEROLETOKEN</SessionToken>
<Expiration>2025-01-01T00:00:00Z</Expiration>
</Credentials>
</AssumeRoleResult>
<ResponseMetadata><RequestId>test</RequestId></ResponseMetadata>
</AssumeRoleResponse>
`,
}
tempDir := t.TempDir()
configContent := "[profile myprofile]\nsource_profile = root\nrole_arn = arn:aws:iam::123:role/myrole\n"
configPath := filepath.Join(tempDir, "config")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("cannot write config: %v", err)
}
credsContent := "[root]\naws_access_key_id = ROOTAKID\naws_secret_access_key = ROOTSECRET\n"
credsPath := filepath.Join(tempDir, "credentials")
if err := os.WriteFile(credsPath, []byte(credsContent), 0600); err != nil {
t.Fatalf("cannot write credentials: %v", err)
}
t.Setenv("AWS_CONFIG_FILE", configPath)
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", credsPath)
rt := &fakeRoundTripper{responses: make(map[string]*http.Response)}
for action, value := range responses {
recorder := httptest.NewRecorder()
recorder.WriteHeader(http.StatusOK)
_, _ = recorder.WriteString(value)
rt.responses[action] = recorder.Result()
}
cfg := &Config{
profile: "myprofile",
stsEndpoint: "http://sts.fake",
client: &http.Client{Transport: rt},
}
creds, err := cfg.getAPICredentials()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
credsExpected := &credentials{
AccessKeyID: "PROFILEROLEID",
SecretAccessKey: "PROFILEROLESECRET",
Token: "PROFILEROLETOKEN",
Expiration: mustParseRFC3339("2025-01-01T00:00:00Z"),
}
if !reflect.DeepEqual(creds, credsExpected) {
t.Fatalf("unexpected creds;\ngot\n%+v\nwant\n%+v", creds, credsExpected)
}
}
func mustParseRFC3339(s string) time.Time {
expTime, err := time.Parse(time.RFC3339, s)
if err != nil {

View File

@@ -37,7 +37,7 @@ func newAPIConfig(sdc *SDConfig) (*apiConfig, error) {
if stsEndpoint == "" {
stsEndpoint = sdc.Endpoint
}
awsCfg, err := awsapi.NewConfig(sdc.Endpoint, stsEndpoint, sdc.Region, sdc.RoleARN, sdc.AccessKey, sdc.SecretKey.String(), "ec2")
awsCfg, err := awsapi.NewConfig(sdc.Endpoint, stsEndpoint, sdc.Region, sdc.RoleARN, sdc.AccessKey, sdc.SecretKey.String(), "ec2", sdc.Profile)
if err != nil {
return nil, err
}

View File

@@ -24,8 +24,8 @@ type SDConfig struct {
STSEndpoint string `yaml:"sts_endpoint,omitempty"`
AccessKey string `yaml:"access_key,omitempty"`
SecretKey *promauth.Secret `yaml:"secret_key,omitempty"`
// TODO add support for Profile, not working atm
// Profile string `yaml:"profile,omitempty"`
// Profile is the named AWS profile from ~/.aws/config and ~/.aws/credentials.
Profile string `yaml:"profile,omitempty"`
RoleARN string `yaml:"role_arn,omitempty"`
// RefreshInterval time.Duration `yaml:"refresh_interval"`
// refresh_interval is obtained from `-promscrape.ec2SDCheckInterval` command-line option.