From 9aea4e91210c5f202f1db9411a4576ff4d988c0a Mon Sep 17 00:00:00 2001 From: Pablo Osinaga Date: Thu, 22 Jan 2026 14:04:06 -0400 Subject: [PATCH 1/3] feat(discovery-jobs): support for IncludeLinkedAccounts Signed-off-by: Pablo Osinaga --- pkg/clients/cloudwatch/client.go | 6 +- pkg/clients/cloudwatch/v1/client.go | 47 +++-- pkg/clients/cloudwatch/v1/client_test.go | 172 +++++++++++++++++ pkg/clients/cloudwatch/v2/client.go | 47 +++-- pkg/clients/cloudwatch/v2/client_test.go | 173 ++++++++++++++++++ pkg/clients/tagging/v1/client.go | 7 + pkg/clients/tagging/v2/client.go | 7 + pkg/clients/v2/factory_test.go | 2 +- pkg/config/config.go | 4 + pkg/config/config_test.go | 1 + .../testdata/include_linked_accounts.ok.yml | 41 +++++ pkg/exporter_test.go | 2 +- pkg/job/custom.go | 11 +- pkg/job/discovery.go | 11 +- pkg/job/discovery_test.go | 62 +++++++ pkg/model/model.go | 18 +- pkg/promutil/migrate.go | 5 + pkg/promutil/migrate_test.go | 55 ++++++ 18 files changed, 627 insertions(+), 44 deletions(-) create mode 100644 pkg/config/testdata/include_linked_accounts.ok.yml diff --git a/pkg/clients/cloudwatch/client.go b/pkg/clients/cloudwatch/client.go index 9d98ecf83..93534d079 100644 --- a/pkg/clients/cloudwatch/client.go +++ b/pkg/clients/cloudwatch/client.go @@ -30,7 +30,7 @@ type Client interface { // ListMetrics returns the list of metrics and dimensions for a given namespace // and metric name. Results pagination is handled automatically: the caller can // optionally pass a non-nil func in order to handle results pages. - ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, recentlyActiveOnly bool, fn func(page []*model.Metric)) error + ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, includeLinkedAccounts []string, recentlyActiveOnly bool, fn func(page []*model.Metric)) error // GetMetricData returns the output of the GetMetricData CloudWatch API. // Results pagination is handled automatically. @@ -90,9 +90,9 @@ func (c limitedConcurrencyClient) GetMetricData(ctx context.Context, getMetricDa return res } -func (c limitedConcurrencyClient) ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, recentlyActiveOnly bool, fn func(page []*model.Metric)) error { +func (c limitedConcurrencyClient) ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, includeLinkedAccounts []string, recentlyActiveOnly bool, fn func(page []*model.Metric)) error { c.limiter.Acquire(listMetricsCall) - err := c.client.ListMetrics(ctx, namespace, metric, recentlyActiveOnly, fn) + err := c.client.ListMetrics(ctx, namespace, metric, includeLinkedAccounts, recentlyActiveOnly, fn) c.limiter.Release(listMetricsCall) return err } diff --git a/pkg/clients/cloudwatch/v1/client.go b/pkg/clients/cloudwatch/v1/client.go index 92081df02..e7b513086 100644 --- a/pkg/clients/cloudwatch/v1/client.go +++ b/pkg/clients/cloudwatch/v1/client.go @@ -15,6 +15,7 @@ package v1 import ( "context" "log/slog" + "slices" "time" "github.com/aws/aws-sdk-go/aws" @@ -38,11 +39,14 @@ func NewClient(logger *slog.Logger, cloudwatchAPI cloudwatchiface.CloudWatchAPI) } } -func (c client) ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, recentlyActiveOnly bool, fn func(page []*model.Metric)) error { +func (c client) ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, includeLinkedAccounts []string, recentlyActiveOnly bool, fn func(page []*model.Metric)) error { filter := &cloudwatch.ListMetricsInput{ MetricName: aws.String(metric.Name), Namespace: aws.String(namespace), } + if len(includeLinkedAccounts) > 0 { + filter.IncludeLinkedAccounts = aws.Bool(true) + } if recentlyActiveOnly { filter.RecentlyActive = aws.String("PT3H") } @@ -52,7 +56,7 @@ func (c client) ListMetrics(ctx context.Context, namespace string, metric *model err := c.cloudwatchAPI.ListMetricsPagesWithContext(ctx, filter, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool { promutil.CloudwatchAPICounter.WithLabelValues("ListMetrics").Inc() - metricsPage := toModelMetric(page) + metricsPage := toModelMetric(page, includeLinkedAccounts) c.logger.Debug("ListMetrics", "output", metricsPage, "last_page", lastPage) @@ -68,15 +72,32 @@ func (c client) ListMetrics(ctx context.Context, namespace string, metric *model return nil } -func toModelMetric(page *cloudwatch.ListMetricsOutput) []*model.Metric { +func toModelMetric(page *cloudwatch.ListMetricsOutput, includeLinkedAccounts []string) []*model.Metric { modelMetrics := make([]*model.Metric, 0, len(page.Metrics)) - for _, cloudwatchMetric := range page.Metrics { - modelMetric := &model.Metric{ - MetricName: *cloudwatchMetric.MetricName, - Namespace: *cloudwatchMetric.Namespace, - Dimensions: toModelDimensions(cloudwatchMetric.Dimensions), + if len(includeLinkedAccounts) > 0 { + includeAll := slices.Contains(includeLinkedAccounts, "*") + for i := 0; i < len(page.Metrics); i++ { + linkedAccountID := *page.OwningAccounts[i] + if !includeAll && !slices.Contains(includeLinkedAccounts, linkedAccountID) { + continue + } + modelMetric := &model.Metric{ + MetricName: *page.Metrics[i].MetricName, + Namespace: *page.Metrics[i].Namespace, + Dimensions: toModelDimensions(page.Metrics[i].Dimensions), + LinkedAccountID: linkedAccountID, + } + modelMetrics = append(modelMetrics, modelMetric) + } + } else { + for _, cloudwatchMetric := range page.Metrics { + modelMetric := &model.Metric{ + MetricName: *cloudwatchMetric.MetricName, + Namespace: *cloudwatchMetric.Namespace, + Dimensions: toModelDimensions(cloudwatchMetric.Dimensions), + } + modelMetrics = append(modelMetrics, modelMetric) } - modelMetrics = append(modelMetrics, modelMetric) } return modelMetrics } @@ -106,12 +127,16 @@ func (c client) GetMetricData(ctx context.Context, getMetricData []*model.Cloudw Period: &data.GetMetricDataProcessingParams.Period, Stat: &data.GetMetricDataProcessingParams.Statistic, } - metricDataQueries = append(metricDataQueries, &cloudwatch.MetricDataQuery{ + metricDataQuery := &cloudwatch.MetricDataQuery{ Id: &data.GetMetricDataProcessingParams.QueryID, MetricStat: metricStat, ReturnData: aws.Bool(true), - }) + } exportAllDataPoints = exportAllDataPoints || data.MetricMigrationParams.ExportAllDataPoints + if data.LinkedAccountID != "" { + metricDataQuery.AccountId = aws.String(data.LinkedAccountID) + } + metricDataQueries = append(metricDataQueries, metricDataQuery) } input := &cloudwatch.GetMetricDataInput{ EndTime: &endTime, diff --git a/pkg/clients/cloudwatch/v1/client_test.go b/pkg/clients/cloudwatch/v1/client_test.go index b3553b5a4..84761035a 100644 --- a/pkg/clients/cloudwatch/v1/client_test.go +++ b/pkg/clients/cloudwatch/v1/client_test.go @@ -151,3 +151,175 @@ func Test_toMetricDataResult(t *testing.T) { }) } } + +func Test_toModelMetric(t *testing.T) { + type testCase struct { + name string + listMetricsOutput *cloudwatch.ListMetricsOutput + includeLinkedAccounts []string + expectedMetrics []*model.Metric + } + + testCases := []testCase{ + { + name: "no linked accounts filter - original behavior", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []*cloudwatch.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + { + MetricName: aws.String("NetworkIn"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-67890")}, + }, + }, + }, + }, + includeLinkedAccounts: nil, + expectedMetrics: []*model.Metric{ + { + MetricName: "CPUUtilization", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-12345"}, + }, + }, + { + MetricName: "NetworkIn", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-67890"}, + }, + }, + }, + }, + { + name: "with wildcard linked accounts - include all", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []*cloudwatch.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + { + MetricName: aws.String("NetworkIn"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-67890")}, + }, + }, + }, + OwningAccounts: []*string{ + aws.String("111111111111"), + aws.String("222222222222"), + }, + }, + includeLinkedAccounts: []string{"*"}, + expectedMetrics: []*model.Metric{ + { + MetricName: "CPUUtilization", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-12345"}, + }, + LinkedAccountID: "111111111111", + }, + { + MetricName: "NetworkIn", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-67890"}, + }, + LinkedAccountID: "222222222222", + }, + }, + }, + { + name: "with specific linked accounts - filter by account ID", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []*cloudwatch.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + { + MetricName: aws.String("NetworkIn"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-67890")}, + }, + }, + { + MetricName: aws.String("DiskReadOps"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-11111")}, + }, + }, + }, + OwningAccounts: []*string{ + aws.String("111111111111"), + aws.String("222222222222"), + aws.String("333333333333"), + }, + }, + includeLinkedAccounts: []string{"111111111111", "333333333333"}, + expectedMetrics: []*model.Metric{ + { + MetricName: "CPUUtilization", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-12345"}, + }, + LinkedAccountID: "111111111111", + }, + { + MetricName: "DiskReadOps", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-11111"}, + }, + LinkedAccountID: "333333333333", + }, + }, + }, + { + name: "with linked accounts filter - no matches", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []*cloudwatch.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []*cloudwatch.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + }, + OwningAccounts: []*string{ + aws.String("111111111111"), + }, + }, + includeLinkedAccounts: []string{"999999999999"}, + expectedMetrics: []*model.Metric{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := toModelMetric(tc.listMetricsOutput, tc.includeLinkedAccounts) + require.Equal(t, tc.expectedMetrics, result) + }) + } +} diff --git a/pkg/clients/cloudwatch/v2/client.go b/pkg/clients/cloudwatch/v2/client.go index 0ba6933bd..ba0f028a2 100644 --- a/pkg/clients/cloudwatch/v2/client.go +++ b/pkg/clients/cloudwatch/v2/client.go @@ -15,6 +15,7 @@ package v2 import ( "context" "log/slog" + "slices" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -38,11 +39,14 @@ func NewClient(logger *slog.Logger, cloudwatchAPI *cloudwatch.Client) cloudwatch } } -func (c client) ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, recentlyActiveOnly bool, fn func(page []*model.Metric)) error { +func (c client) ListMetrics(ctx context.Context, namespace string, metric *model.MetricConfig, includeLinkedAccounts []string, recentlyActiveOnly bool, fn func(page []*model.Metric)) error { filter := &cloudwatch.ListMetricsInput{ MetricName: aws.String(metric.Name), Namespace: aws.String(namespace), } + if len(includeLinkedAccounts) > 0 { + filter.IncludeLinkedAccounts = aws.Bool(true) + } if recentlyActiveOnly { filter.RecentlyActive = types.RecentlyActivePt3h } @@ -62,7 +66,7 @@ func (c client) ListMetrics(ctx context.Context, namespace string, metric *model return err } - metricsPage := toModelMetric(page) + metricsPage := toModelMetric(page, includeLinkedAccounts) c.logger.Debug("ListMetrics", "output", metricsPage) fn(metricsPage) @@ -71,15 +75,32 @@ func (c client) ListMetrics(ctx context.Context, namespace string, metric *model return nil } -func toModelMetric(page *cloudwatch.ListMetricsOutput) []*model.Metric { +func toModelMetric(page *cloudwatch.ListMetricsOutput, includeLinkedAccounts []string) []*model.Metric { modelMetrics := make([]*model.Metric, 0, len(page.Metrics)) - for _, cloudwatchMetric := range page.Metrics { - modelMetric := &model.Metric{ - MetricName: *cloudwatchMetric.MetricName, - Namespace: *cloudwatchMetric.Namespace, - Dimensions: toModelDimensions(cloudwatchMetric.Dimensions), + if len(includeLinkedAccounts) > 0 { + includeAll := slices.Contains(includeLinkedAccounts, "*") + for i := 0; i < len(page.Metrics); i++ { + linkedAccountID := page.OwningAccounts[i] + if !includeAll && !slices.Contains(includeLinkedAccounts, linkedAccountID) { + continue + } + modelMetric := &model.Metric{ + MetricName: *page.Metrics[i].MetricName, + Namespace: *page.Metrics[i].Namespace, + Dimensions: toModelDimensions(page.Metrics[i].Dimensions), + LinkedAccountID: linkedAccountID, + } + modelMetrics = append(modelMetrics, modelMetric) + } + } else { + for _, cloudwatchMetric := range page.Metrics { + modelMetric := &model.Metric{ + MetricName: *cloudwatchMetric.MetricName, + Namespace: *cloudwatchMetric.Namespace, + Dimensions: toModelDimensions(cloudwatchMetric.Dimensions), + } + modelMetrics = append(modelMetrics, modelMetric) } - modelMetrics = append(modelMetrics, modelMetric) } return modelMetrics } @@ -109,12 +130,16 @@ func (c client) GetMetricData(ctx context.Context, getMetricData []*model.Cloudw Period: aws.Int32(int32(data.GetMetricDataProcessingParams.Period)), Stat: &data.GetMetricDataProcessingParams.Statistic, } - metricDataQueries = append(metricDataQueries, types.MetricDataQuery{ + metricDataQuery := types.MetricDataQuery{ Id: &data.GetMetricDataProcessingParams.QueryID, MetricStat: metricStat, ReturnData: aws.Bool(true), - }) + } exportAllDataPoints = exportAllDataPoints || data.MetricMigrationParams.ExportAllDataPoints + if data.LinkedAccountID != "" { + metricDataQuery.AccountId = aws.String(data.LinkedAccountID) + } + metricDataQueries = append(metricDataQueries, metricDataQuery) } input := &cloudwatch.GetMetricDataInput{ diff --git a/pkg/clients/cloudwatch/v2/client_test.go b/pkg/clients/cloudwatch/v2/client_test.go index f45cfe434..b7861eee1 100644 --- a/pkg/clients/cloudwatch/v2/client_test.go +++ b/pkg/clients/cloudwatch/v2/client_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/require" cloudwatch_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/model" ) func Test_toMetricDataResult(t *testing.T) { @@ -136,3 +137,175 @@ func Test_toMetricDataResult(t *testing.T) { }) } } + +func Test_toModelMetric(t *testing.T) { + type testCase struct { + name string + listMetricsOutput *cloudwatch.ListMetricsOutput + includeLinkedAccounts []string + expectedMetrics []*model.Metric + } + + testCases := []testCase{ + { + name: "no linked accounts filter - original behavior", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []types.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + { + MetricName: aws.String("NetworkIn"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-67890")}, + }, + }, + }, + }, + includeLinkedAccounts: nil, + expectedMetrics: []*model.Metric{ + { + MetricName: "CPUUtilization", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-12345"}, + }, + }, + { + MetricName: "NetworkIn", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-67890"}, + }, + }, + }, + }, + { + name: "with wildcard linked accounts - include all", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []types.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + { + MetricName: aws.String("NetworkIn"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-67890")}, + }, + }, + }, + OwningAccounts: []string{ + "111111111111", + "222222222222", + }, + }, + includeLinkedAccounts: []string{"*"}, + expectedMetrics: []*model.Metric{ + { + MetricName: "CPUUtilization", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-12345"}, + }, + LinkedAccountID: "111111111111", + }, + { + MetricName: "NetworkIn", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-67890"}, + }, + LinkedAccountID: "222222222222", + }, + }, + }, + { + name: "with specific linked accounts - filter by account ID", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []types.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + { + MetricName: aws.String("NetworkIn"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-67890")}, + }, + }, + { + MetricName: aws.String("DiskReadOps"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-11111")}, + }, + }, + }, + OwningAccounts: []string{ + "111111111111", + "222222222222", + "333333333333", + }, + }, + includeLinkedAccounts: []string{"111111111111", "333333333333"}, + expectedMetrics: []*model.Metric{ + { + MetricName: "CPUUtilization", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-12345"}, + }, + LinkedAccountID: "111111111111", + }, + { + MetricName: "DiskReadOps", + Namespace: "AWS/EC2", + Dimensions: []model.Dimension{ + {Name: "InstanceId", Value: "i-11111"}, + }, + LinkedAccountID: "333333333333", + }, + }, + }, + { + name: "with linked accounts filter - no matches", + listMetricsOutput: &cloudwatch.ListMetricsOutput{ + Metrics: []types.Metric{ + { + MetricName: aws.String("CPUUtilization"), + Namespace: aws.String("AWS/EC2"), + Dimensions: []types.Dimension{ + {Name: aws.String("InstanceId"), Value: aws.String("i-12345")}, + }, + }, + }, + OwningAccounts: []string{ + "111111111111", + }, + }, + includeLinkedAccounts: []string{"999999999999"}, + expectedMetrics: []*model.Metric{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := toModelMetric(tc.listMetricsOutput, tc.includeLinkedAccounts) + require.Equal(t, tc.expectedMetrics, result) + }) + } +} diff --git a/pkg/clients/tagging/v1/client.go b/pkg/clients/tagging/v1/client.go index f0f6624f6..96997caf3 100644 --- a/pkg/clients/tagging/v1/client.go +++ b/pkg/clients/tagging/v1/client.go @@ -79,6 +79,13 @@ func (c client) GetResources(ctx context.Context, job model.DiscoveryJob, region var resources []*model.TaggedResource shouldHaveDiscoveredResources := false + if len(job.IncludeLinkedAccounts) > 0 { + // when setting `includeLinkedAccounts`, don't get resources in cross accounts (because we need more permissions in cross accounts) + c.logger.Warn("Return empty resources when enable includeLinkedAccounts") + resources = []*model.TaggedResource{} + return resources, nil + } + if len(svc.ResourceFilters) > 0 { shouldHaveDiscoveredResources = true diff --git a/pkg/clients/tagging/v2/client.go b/pkg/clients/tagging/v2/client.go index 5f0d704ef..a59a714d7 100644 --- a/pkg/clients/tagging/v2/client.go +++ b/pkg/clients/tagging/v2/client.go @@ -79,6 +79,13 @@ func (c client) GetResources(ctx context.Context, job model.DiscoveryJob, region var resources []*model.TaggedResource shouldHaveDiscoveredResources := false + if len(job.IncludeLinkedAccounts) > 0 { + // when setting `includeLinkedAccounts`, don't get resources in cross accounts (because we need more permissions in cross accounts) + c.logger.Warn("Return empty resources when enable includeLinkedAccounts") + resources = []*model.TaggedResource{} + return resources, nil + } + if len(svc.ResourceFilters) > 0 { shouldHaveDiscoveredResources = true filters := make([]string, 0, len(svc.ResourceFilters)) diff --git a/pkg/clients/v2/factory_test.go b/pkg/clients/v2/factory_test.go index 6d7836daa..5181ce94e 100644 --- a/pkg/clients/v2/factory_test.go +++ b/pkg/clients/v2/factory_test.go @@ -528,7 +528,7 @@ func (t testClient) GetAccountAlias(_ context.Context) (string, error) { return "", nil } -func (t testClient) ListMetrics(_ context.Context, _ string, _ *model.MetricConfig, _ bool, _ func(page []*model.Metric)) error { +func (t testClient) ListMetrics(_ context.Context, _ string, _ *model.MetricConfig, _ []string, _ bool, _ func(page []*model.Metric)) error { return nil } diff --git a/pkg/config/config.go b/pkg/config/config.go index 3ce9ea27a..2ddff8267 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -67,6 +67,7 @@ type Job struct { RoundingPeriod *int64 `yaml:"roundingPeriod"` RecentlyActiveOnly bool `yaml:"recentlyActiveOnly"` IncludeContextOnInfoMetrics bool `yaml:"includeContextOnInfoMetrics"` + IncludeLinkedAccounts []string `yaml:"includeLinkedAccounts"` EnhancedMetrics []*EnhancedMetric `yaml:"enhancedMetrics"` JobLevelMetricFields `yaml:",inline"` } @@ -95,6 +96,7 @@ type CustomNamespace struct { CustomTags []Tag `yaml:"customTags"` DimensionNameRequirements []string `yaml:"dimensionNameRequirements"` RoundingPeriod *int64 `yaml:"roundingPeriod"` + IncludeLinkedAccounts []string `yaml:"includeLinkedAccounts"` JobLevelMetricFields `yaml:",inline"` } @@ -456,6 +458,7 @@ func (c *ScrapeConf) toModelConfig() model.JobsConfig { job.CustomTags = toModelTags(discoveryJob.CustomTags) job.Metrics = toModelMetricConfig(discoveryJob.Metrics) job.IncludeContextOnInfoMetrics = discoveryJob.IncludeContextOnInfoMetrics + job.IncludeLinkedAccounts = discoveryJob.IncludeLinkedAccounts job.DimensionsRegexps = svc.ToModelDimensionsRegexp() job.EnhancedMetrics = svc.toModelEnhancedMetricsConfig(discoveryJob.EnhancedMetrics) @@ -492,6 +495,7 @@ func (c *ScrapeConf) toModelConfig() model.JobsConfig { job.Roles = toModelRoles(customNamespaceJob.Roles) job.CustomTags = toModelTags(customNamespaceJob.CustomTags) job.Metrics = toModelMetricConfig(customNamespaceJob.Metrics) + job.IncludeLinkedAccounts = customNamespaceJob.IncludeLinkedAccounts jobsCfg.CustomNamespaceJobs = append(jobsCfg.CustomNamespaceJobs, job) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index ecadd9acc..69ca13abc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -30,6 +30,7 @@ func TestConfLoad(t *testing.T) { {configFile: "sts_region.ok.yml"}, {configFile: "multiple_roles.ok.yml"}, {configFile: "custom_namespace.ok.yml"}, + {configFile: "include_linked_accounts.ok.yml"}, } for _, tc := range testCases { config := ScrapeConf{} diff --git a/pkg/config/testdata/include_linked_accounts.ok.yml b/pkg/config/testdata/include_linked_accounts.ok.yml new file mode 100644 index 000000000..c33225bf4 --- /dev/null +++ b/pkg/config/testdata/include_linked_accounts.ok.yml @@ -0,0 +1,41 @@ +apiVersion: v1alpha1 +sts-region: eu-west-1 +discovery: + jobs: + - type: AWS/EC2 + regions: + - us-east-1 + includeLinkedAccounts: + - "111111111111" + - "222222222222" + metrics: + - name: CPUUtilization + statistics: + - Average + period: 300 + length: 300 + - type: AWS/Lambda + regions: + - us-east-1 + includeLinkedAccounts: + - "*" + metrics: + - name: Invocations + statistics: + - Sum + period: 60 + length: 300 +customNamespace: + - name: customMetrics + namespace: CustomEC2Metrics + regions: + - us-east-1 + includeLinkedAccounts: + - "333333333333" + metrics: + - name: cpu_usage_idle + statistics: + - Average + period: 300 + length: 300 + nilToZero: true diff --git a/pkg/exporter_test.go b/pkg/exporter_test.go index e528805f7..716ca2d26 100644 --- a/pkg/exporter_test.go +++ b/pkg/exporter_test.go @@ -93,7 +93,7 @@ type mockCloudwatchClient struct { err error } -func (m *mockCloudwatchClient) ListMetrics(_ context.Context, _ string, _ *model.MetricConfig, _ bool, fn func(page []*model.Metric)) error { +func (m *mockCloudwatchClient) ListMetrics(_ context.Context, _ string, _ *model.MetricConfig, _ []string, _ bool, fn func(page []*model.Metric)) error { if m.err != nil { return m.err } diff --git a/pkg/job/custom.go b/pkg/job/custom.go index cd4a11bdb..aab7f8de9 100644 --- a/pkg/job/custom.go +++ b/pkg/job/custom.go @@ -63,7 +63,7 @@ func getMetricDataForQueriesForCustomNamespace( go func(metric *model.MetricConfig) { defer wg.Done() - err := clientCloudwatch.ListMetrics(ctx, customNamespaceJob.Namespace, metric, customNamespaceJob.RecentlyActiveOnly, func(page []*model.Metric) { + err := clientCloudwatch.ListMetrics(ctx, customNamespaceJob.Namespace, metric, customNamespaceJob.IncludeLinkedAccounts, customNamespaceJob.RecentlyActiveOnly, func(page []*model.Metric) { var data []*model.CloudwatchData for _, cwMetric := range page { @@ -73,10 +73,11 @@ func getMetricDataForQueriesForCustomNamespace( for _, stat := range metric.Statistics { data = append(data, &model.CloudwatchData{ - MetricName: metric.Name, - ResourceName: customNamespaceJob.Name, - Namespace: customNamespaceJob.Namespace, - Dimensions: cwMetric.Dimensions, + MetricName: metric.Name, + ResourceName: customNamespaceJob.Name, + LinkedAccountID: cwMetric.LinkedAccountID, + Namespace: customNamespaceJob.Namespace, + Dimensions: cwMetric.Dimensions, GetMetricDataProcessingParams: &model.GetMetricDataProcessingParams{ Period: metric.Period, Length: metric.Length, diff --git a/pkg/job/discovery.go b/pkg/job/discovery.go index cc5cf9132..5a742ad17 100644 --- a/pkg/job/discovery.go +++ b/pkg/job/discovery.go @@ -149,7 +149,7 @@ func getMetricDataForQueries( go func(metric *model.MetricConfig) { defer wg.Done() - err := clientCloudwatch.ListMetrics(ctx, svc.Namespace, metric, discoveryJob.RecentlyActiveOnly, func(page []*model.Metric) { + err := clientCloudwatch.ListMetrics(ctx, svc.Namespace, metric, discoveryJob.IncludeLinkedAccounts, discoveryJob.RecentlyActiveOnly, func(page []*model.Metric) { data := getFilteredMetricDatas(logger, discoveryJob.Namespace, discoveryJob.ExportedTagsOnMetrics, page, discoveryJob.DimensionNameRequirements, metric, assoc) mux.Lock() @@ -210,10 +210,11 @@ func getFilteredMetricDatas( metricTags := resource.MetricTags(tagsOnMetrics) for _, stat := range m.Statistics { getMetricsData = append(getMetricsData, &model.CloudwatchData{ - MetricName: m.Name, - ResourceName: resource.ARN, - Namespace: namespace, - Dimensions: cwMetric.Dimensions, + MetricName: m.Name, + ResourceName: resource.ARN, + LinkedAccountID: cwMetric.LinkedAccountID, + Namespace: namespace, + Dimensions: cwMetric.Dimensions, GetMetricDataProcessingParams: &model.GetMetricDataProcessingParams{ Period: m.Period, Length: m.Length, diff --git a/pkg/job/discovery_test.go b/pkg/job/discovery_test.go index a9407789c..725584e71 100644 --- a/pkg/job/discovery_test.go +++ b/pkg/job/discovery_test.go @@ -479,6 +479,67 @@ func Test_getFilteredMetricDatas(t *testing.T) { }, }, }, + { + "ec2 with LinkedAccountID propagation", + args{ + region: "us-east-1", + accountID: "123123123123", + namespace: "ec2", + customTags: nil, + tagsOnMetrics: nil, + dimensionRegexps: config.SupportedServices.GetService("AWS/EC2").ToModelDimensionsRegexp(), + resources: []*model.TaggedResource{}, + metricsList: []*model.Metric{ + { + MetricName: "CPUUtilization", + Dimensions: []model.Dimension{ + { + Name: "InstanceId", + Value: "i-12312312312312312", + }, + }, + Namespace: "AWS/EC2", + LinkedAccountID: "999888777666", + }, + }, + m: &model.MetricConfig{ + Name: "CPUUtilization", + Statistics: []string{ + "Average", + }, + Period: 60, + Length: 600, + Delay: 120, + NilToZero: false, + AddCloudwatchTimestamp: false, + }, + }, + []model.CloudwatchData{ + { + MetricName: "CPUUtilization", + ResourceName: "global", + Namespace: "ec2", + Dimensions: []model.Dimension{ + { + Name: "InstanceId", + Value: "i-12312312312312312", + }, + }, + LinkedAccountID: "999888777666", + Tags: []model.Tag{}, + GetMetricDataProcessingParams: &model.GetMetricDataProcessingParams{ + Statistic: "Average", + Period: 60, + Length: 600, + Delay: 120, + }, + MetricMigrationParams: model.MetricMigrationParams{ + NilToZero: false, + AddCloudwatchTimestamp: false, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -494,6 +555,7 @@ func Test_getFilteredMetricDatas(t *testing.T) { assert.Equal(t, want.Namespace, got.Namespace) assert.ElementsMatch(t, want.Dimensions, got.Dimensions) assert.ElementsMatch(t, want.Tags, got.Tags) + assert.Equal(t, want.LinkedAccountID, got.LinkedAccountID) assert.Equal(t, want.MetricMigrationParams, got.MetricMigrationParams) assert.Equal(t, want.GetMetricDataProcessingParams.Statistic, got.GetMetricDataProcessingParams.Statistic) assert.Equal(t, want.GetMetricDataProcessingParams.Length, got.GetMetricDataProcessingParams.Length) diff --git a/pkg/model/model.go b/pkg/model/model.go index bef0481e7..b84bae583 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -42,6 +42,7 @@ type DiscoveryJob struct { RecentlyActiveOnly bool ExportedTagsOnMetrics []string IncludeContextOnInfoMetrics bool + IncludeLinkedAccounts []string DimensionsRegexps []DimensionsRegexp // EnhancedMetrics holds configuration for enhanced metrics in discovery jobs. It contains a configuration for the non-CloudWatch metrics to collect. @@ -76,6 +77,7 @@ type CustomNamespaceJob struct { Metrics []*MetricConfig CustomTags []Tag DimensionNameRequirements []string + IncludeLinkedAccounts []string } type Role struct { @@ -118,9 +120,10 @@ type Dimension struct { type Metric struct { // The dimensions for the metric. - Dimensions []Dimension - MetricName string - Namespace string + Dimensions []Dimension + MetricName string + Namespace string + LinkedAccountID string } type CloudwatchMetricResult struct { @@ -148,10 +151,11 @@ type CloudwatchData struct { // DiscoveryJob = Resource ARN associated with the metric or global when it could not be associated but shouldn't be dropped // StaticJob = Resource Name from static job config // CustomNamespace = Custom Namespace job name - ResourceName string - Namespace string - Tags []Tag - Dimensions []Dimension + ResourceName string + Namespace string + Tags []Tag + Dimensions []Dimension + LinkedAccountID string // GetMetricDataProcessingParams includes necessary fields to run GetMetricData GetMetricDataProcessingParams *GetMetricDataProcessingParams diff --git a/pkg/promutil/migrate.go b/pkg/promutil/migrate.go index 024c2ade9..d9a411866 100644 --- a/pkg/promutil/migrate.go +++ b/pkg/promutil/migrate.go @@ -144,6 +144,11 @@ func BuildMetrics(results []model.CloudwatchMetricResult, labelsSnakeCase bool, name := BuildMetricName(metric.Namespace, metric.MetricName, statistic) promLabels := createPrometheusLabels(metric, labelsSnakeCase, contextLabels, logger) + maps.Copy(promLabels, contextLabels) + // When querying linked accounts, override account_id with the metric's owning account + if metric.LinkedAccountID != "" { + promLabels["account_id"] = metric.LinkedAccountID + } observedMetricLabels = recordLabelsForMetric(name, promLabels, observedMetricLabels) if !metric.MetricMigrationParams.AddCloudwatchTimestamp { diff --git a/pkg/promutil/migrate_test.go b/pkg/promutil/migrate_test.go index a3dc55a65..272832ada 100644 --- a/pkg/promutil/migrate_test.go +++ b/pkg/promutil/migrate_test.go @@ -1086,6 +1086,61 @@ func TestBuildMetrics(t *testing.T) { }, expectedErr: nil, }, + { + name: "metric with LinkedAccountID overrides context account_id", + data: []model.CloudwatchMetricResult{{ + Context: &model.ScrapeContext{ + Region: "us-east-1", + AccountID: "123456789012", + CustomTags: nil, + }, + Data: []*model.CloudwatchData{ + { + MetricName: "CPUUtilization", + MetricMigrationParams: model.MetricMigrationParams{ + NilToZero: false, + AddCloudwatchTimestamp: false, + }, + Namespace: "AWS/EC2", + LinkedAccountID: "999888777666", + GetMetricDataResult: &model.GetMetricDataResult{ + Statistic: "Average", + DataPoints: []model.DataPoint{{Value: aws.Float64(50), Timestamp: ts}}, + }, + Dimensions: []model.Dimension{ + { + Name: "InstanceId", + Value: "i-12345", + }, + }, + ResourceName: "arn:aws:ec2:us-east-1:999888777666:instance/i-12345", + }, + }, + }}, + labelsSnakeCase: true, + expectedMetrics: []*PrometheusMetric{ + { + Name: "aws_ec2_cpuutilization_average", + Value: 50, + Timestamp: nullTs, + Labels: map[string]string{ + "account_id": "999888777666", + "name": "arn:aws:ec2:us-east-1:999888777666:instance/i-12345", + "region": "us-east-1", + "dimension_instance_id": "i-12345", + }, + }, + }, + expectedLabels: map[string]model.LabelSet{ + "aws_ec2_cpuutilization_average": { + "account_id": {}, + "name": {}, + "region": {}, + "dimension_instance_id": {}, + }, + }, + expectedErr: nil, + }, } for _, tc := range testCases { From d2da2a66eb6f36be930fd5def71b0c87bd41a79a Mon Sep 17 00:00:00 2001 From: Pablo Osinaga Date: Tue, 10 Feb 2026 16:31:14 +0100 Subject: [PATCH 2/3] feat: add linked account alias resolution --- cmd/oam-linked-accounts-check/main.go | 89 ++++++++++++++++ go.mod | 7 +- go.sum | 14 +-- pkg/clients/factory.go | 2 + pkg/clients/oam/client.go | 22 ++++ pkg/clients/oam/v1/client.go | 73 +++++++++++++ pkg/clients/oam/v2/client.go | 72 +++++++++++++ pkg/clients/v1/factory.go | 60 +++++++++++ pkg/clients/v2/factory.go | 65 ++++++++++++ pkg/config/config.go | 14 ++- pkg/exporter_enhancedmetrics_test.go | 6 ++ pkg/exporter_test.go | 5 + pkg/internal/enhancedmetrics/service_test.go | 9 ++ pkg/job/custom.go | 19 ++-- pkg/job/discovery.go | 23 +++-- pkg/job/discovery_test.go | 4 +- pkg/job/linked_account_alias.go | 64 ++++++++++++ pkg/job/linked_account_alias_test.go | 103 +++++++++++++++++++ pkg/job/scrape.go | 19 +++- pkg/model/model.go | 13 ++- pkg/promutil/migrate.go | 13 ++- pkg/promutil/migrate_test.go | 35 +++++-- 22 files changed, 690 insertions(+), 41 deletions(-) create mode 100644 cmd/oam-linked-accounts-check/main.go create mode 100644 pkg/clients/oam/client.go create mode 100644 pkg/clients/oam/v1/client.go create mode 100644 pkg/clients/oam/v2/client.go create mode 100644 pkg/job/linked_account_alias.go create mode 100644 pkg/job/linked_account_alias_test.go diff --git a/cmd/oam-linked-accounts-check/main.go b/cmd/oam-linked-accounts-check/main.go new file mode 100644 index 000000000..3919b05dd --- /dev/null +++ b/cmd/oam-linked-accounts-check/main.go @@ -0,0 +1,89 @@ +// Copyright 2024 The Prometheus Authors +// 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 main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/oam" +) + +const usage = `Usage: + oam-linked-accounts-check --sink-identifier SINK_ARN [--region REGION] + +Lists all source account links attached to the given OAM sink and prints +each source account ID with its resolved label. + +Notes: + - Requires oam:ListAttachedLinks permission. + - Caller must be in the monitoring account that owns the sink. +` + +func main() { + var sinkIdentifier string + var region string + flag.StringVar(&sinkIdentifier, "sink-identifier", "", "ARN of the OAM sink (required)") + flag.StringVar(®ion, "region", "", "AWS region for OAM API (optional)") + flag.Usage = func() { + fmt.Fprint(flag.CommandLine.Output(), usage) + } + flag.Parse() + + if sinkIdentifier == "" { + flag.Usage() + os.Exit(2) + } + + ctx := context.Background() + loadOptions := []func(*config.LoadOptions) error{} + if region != "" { + loadOptions = append(loadOptions, config.WithRegion(region)) + } + awsConfig, err := config.LoadDefaultConfig(ctx, loadOptions...) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load AWS config: %v\n", err) + os.Exit(1) + } + + client := oam.NewFromConfig(awsConfig) + + paginator := oam.NewListAttachedLinksPaginator(client, &oam.ListAttachedLinksInput{ + SinkIdentifier: &sinkIdentifier, + }) + + count := 0 + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "ListAttachedLinks error: %v\n", err) + os.Exit(1) + } + for _, item := range page.Items { + linkArn := "" + label := "" + if item.LinkArn != nil { + linkArn = *item.LinkArn + } + if item.Label != nil { + label = *item.Label + } + fmt.Printf("link_arn=%s label=%s\n", linkArn, label) + count++ + } + } + fmt.Printf("\nTotal linked accounts: %d\n", count) +} diff --git a/go.mod b/go.mod index 5ae75b4c3..53ddfaed0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( github.com/aws/aws-sdk-go v1.55.7 - github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/config v1.32.0 github.com/aws/aws-sdk-go-v2/credentials v1.19.0 github.com/aws/aws-sdk-go-v2/service/amp v1.42.1 @@ -18,6 +18,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.8 github.com/aws/aws-sdk-go-v2/service/iam v1.52.1 github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 + github.com/aws/aws-sdk-go-v2/service/oam v1.23.11 github.com/aws/aws-sdk-go-v2/service/rds v1.113.1 github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.1 github.com/aws/aws-sdk-go-v2/service/shield v1.34.13 @@ -42,8 +43,8 @@ require ( github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect diff --git a/go.sum b/go.sum index cea4b6494..35a91f1d9 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vS github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.32.0 h1:T5WWJYnam9SzBLbsVYDu2HscLDe+GU1AUJtfcDAc/vA= @@ -14,10 +14,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.0 h1:7zm+ez+qEqLaNsCSRaistkvJRJv8 github.com/aws/aws-sdk-go-v2/credentials v1.19.0/go.mod h1:pHKPblrT7hqFGkNLxqoS3FlGoPrQg4hMIa+4asZzBfs= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/service/amp v1.42.1 h1:fJorvFIiVlizskYTlQUtckFmb21hjkgT1PUSvH8cq/U= @@ -48,6 +48,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 h1:E5UXxF3vK3JuViwKCHfTJBIiFjvE4aytSucZjI2UAlQ= github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0/go.mod h1:6f64Y1BEf6e1uCI+LtGbcZSKDK1GvgJ+iI4vP/bbE8s= +github.com/aws/aws-sdk-go-v2/service/oam v1.23.11 h1:tGBgzz6uJTBdQ6aTg8VNibn6vCqPro6+nhzsw86wxAU= +github.com/aws/aws-sdk-go-v2/service/oam v1.23.11/go.mod h1:6QtLWHhXxj3jblHDwWp4d6R16dAxJXzcD5g7ODv7bOo= github.com/aws/aws-sdk-go-v2/service/rds v1.113.1 h1:/vV0g/Su8rCTqT57UUYiFU/aRrPXz//fGDn1dkXblG4= github.com/aws/aws-sdk-go-v2/service/rds v1.113.1/go.mod h1:q02df+DL73LN+jDXzj86tMsI6kKf1kfv61nB684H+o8= github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.1 h1:dSSvIM4/755D7EkUeUc+BChEC6my1174OZ9U3glm3KI= diff --git a/pkg/clients/factory.go b/pkg/clients/factory.go index 286fe5a43..e5bb773e9 100644 --- a/pkg/clients/factory.go +++ b/pkg/clients/factory.go @@ -15,6 +15,7 @@ package clients import ( "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/account" cloudwatch_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/model" ) @@ -25,4 +26,5 @@ type Factory interface { GetCloudwatchClient(region string, role model.Role, concurrency cloudwatch_client.ConcurrencyConfig) cloudwatch_client.Client GetTaggingClient(region string, role model.Role, concurrencyLimit int) tagging.Client GetAccountClient(region string, role model.Role) account.Client + GetOAMClient(region string, role model.Role) oam.Client } diff --git a/pkg/clients/oam/client.go b/pkg/clients/oam/client.go new file mode 100644 index 000000000..67e24f195 --- /dev/null +++ b/pkg/clients/oam/client.go @@ -0,0 +1,22 @@ +// Copyright 2024 The Prometheus Authors +// 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 oam + +import "context" + +type Client interface { + // ListLinkedAccounts calls OAM ListAttachedLinks for the given sink identifier + // and returns a map of source account ID to resolved label. + // Requires oam:ListAttachedLinks permission. + ListLinkedAccounts(ctx context.Context, sinkIdentifier string) (map[string]string, error) +} diff --git a/pkg/clients/oam/v1/client.go b/pkg/clients/oam/v1/client.go new file mode 100644 index 000000000..4a493de64 --- /dev/null +++ b/pkg/clients/oam/v1/client.go @@ -0,0 +1,73 @@ +// Copyright 2024 The Prometheus Authors +// 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 v1 + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/aws/aws-sdk-go/service/oam" + "github.com/aws/aws-sdk-go/service/oam/oamiface" + + oam_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" +) + +type client struct { + logger *slog.Logger + oamClient oamiface.OAMAPI +} + +func NewClient(logger *slog.Logger, oamClient oamiface.OAMAPI) oam_client.Client { + return &client{ + logger: logger, + oamClient: oamClient, + } +} + +func (c client) ListLinkedAccounts(ctx context.Context, sinkIdentifier string) (map[string]string, error) { + accounts := make(map[string]string) + + input := &oam.ListAttachedLinksInput{ + SinkIdentifier: &sinkIdentifier, + } + + err := c.oamClient.ListAttachedLinksPagesWithContext(ctx, input, func(page *oam.ListAttachedLinksOutput, lastPage bool) bool { + for _, item := range page.Items { + if item.LinkArn == nil || item.Label == nil { + continue + } + accountID := accountIDFromLinkArn(*item.LinkArn) + if accountID != "" { + accounts[accountID] = *item.Label + } + } + return true + }) + if err != nil { + return nil, fmt.Errorf("OAM ListAttachedLinks for sink %s: %w", sinkIdentifier, err) + } + + return accounts, nil +} + +// accountIDFromLinkArn extracts the source account ID from a link ARN. +// Link ARN format: arn:aws:oam:REGION:ACCOUNT_ID:link/LINK_ID +func accountIDFromLinkArn(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) >= 5 { + return parts[4] + } + return "" +} diff --git a/pkg/clients/oam/v2/client.go b/pkg/clients/oam/v2/client.go new file mode 100644 index 000000000..037a3fef8 --- /dev/null +++ b/pkg/clients/oam/v2/client.go @@ -0,0 +1,72 @@ +// Copyright 2024 The Prometheus Authors +// 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 v2 + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/oam" + + oam_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" +) + +type client struct { + logger *slog.Logger + oamClient *oam.Client +} + +func NewClient(logger *slog.Logger, oamClient *oam.Client) oam_client.Client { + return &client{ + logger: logger, + oamClient: oamClient, + } +} + +func (c client) ListLinkedAccounts(ctx context.Context, sinkIdentifier string) (map[string]string, error) { + accounts := make(map[string]string) + + paginator := oam.NewListAttachedLinksPaginator(c.oamClient, &oam.ListAttachedLinksInput{ + SinkIdentifier: &sinkIdentifier, + }) + + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("OAM ListAttachedLinks for sink %s: %w", sinkIdentifier, err) + } + for _, item := range page.Items { + if item.LinkArn == nil || item.Label == nil { + continue + } + accountID := accountIDFromLinkArn(*item.LinkArn) + if accountID != "" { + accounts[accountID] = *item.Label + } + } + } + + return accounts, nil +} + +// accountIDFromLinkArn extracts the source account ID from a link ARN. +// Link ARN format: arn:aws:oam:REGION:ACCOUNT_ID:link/LINK_ID +func accountIDFromLinkArn(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) >= 5 { + return parts[4] + } + return "" +} diff --git a/pkg/clients/v1/factory.go b/pkg/clients/v1/factory.go index d6879ddd5..dc7e868fc 100644 --- a/pkg/clients/v1/factory.go +++ b/pkg/clients/v1/factory.go @@ -37,6 +37,7 @@ import ( "github.com/aws/aws-sdk-go/service/ec2/ec2iface" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/iam/iamiface" + aws_oam "github.com/aws/aws-sdk-go/service/oam" "github.com/aws/aws-sdk-go/service/prometheusservice" "github.com/aws/aws-sdk-go/service/prometheusservice/prometheusserviceiface" "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" @@ -53,6 +54,8 @@ import ( account_v1 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/account/v1" cloudwatch_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" cloudwatch_v1 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch/v1" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" + oam_v1 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam/v1" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging" tagging_v1 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging/v1" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/model" @@ -80,6 +83,7 @@ type cachedClients struct { cloudwatch cloudwatch_client.Client tagging tagging.Client account account.Client + oam oam.Client } // Ensure the struct properly implements the interface @@ -156,6 +160,34 @@ func NewFactory(logger *slog.Logger, jobsCfg model.JobsConfig, fips bool) *Cachi } } + // Ensure OAM region exists in cache when using linked account aliases (GetOAMClient is called with oamRegion) + if jobsCfg.OAMSinkIdentifier != "" && jobsCfg.OAMRegion != "" { + for _, discoveryJob := range jobsCfg.DiscoveryJobs { + if len(discoveryJob.IncludeLinkedAccounts) > 0 { + for _, role := range discoveryJob.Roles { + if _, ok := cache[role]; !ok { + cache[role] = map[string]*cachedClients{} + } + if _, exists := cache[role][jobsCfg.OAMRegion]; !exists { + cache[role][jobsCfg.OAMRegion] = &cachedClients{onlyStatic: true} + } + } + } + } + for _, customNamespaceJob := range jobsCfg.CustomNamespaceJobs { + if len(customNamespaceJob.IncludeLinkedAccounts) > 0 { + for _, role := range customNamespaceJob.Roles { + if _, ok := cache[role]; !ok { + cache[role] = map[string]*cachedClients{} + } + if _, exists := cache[role][jobsCfg.OAMRegion]; !exists { + cache[role][jobsCfg.OAMRegion] = &cachedClients{onlyStatic: true} + } + } + } + } + } + endpointResolver := endpoints.DefaultResolver().EndpointFor endpointURLOverride := os.Getenv("AWS_ENDPOINT_URL") @@ -208,6 +240,7 @@ func (c *CachingFactory) Clear() { cachedClient.account = nil cachedClient.cloudwatch = nil cachedClient.tagging = nil + cachedClient.oam = nil } } c.cleared.Store(true) @@ -326,6 +359,33 @@ func (c *CachingFactory) GetAccountClient(region string, role model.Role) accoun return c.clients[role][region].account } +func (c *CachingFactory) GetOAMClient(region string, role model.Role) oam.Client { + if !c.refreshed.Load() { + c.mu.Lock() + defer c.mu.Unlock() + } + if client := c.clients[role][region].oam; client != nil { + return client + } + c.clients[role][region].oam = createOAMClient(c.logger, c.session, ®ion, role, c.fips) + return c.clients[role][region].oam +} + +func createOAMClient(logger *slog.Logger, sess *session.Session, region *string, role model.Role, fips bool) oam.Client { + maxRetries := 5 + config := &aws.Config{Region: region, MaxRetries: &maxRetries} + + if fips { + config.UseFIPSEndpoint = endpoints.FIPSEndpointStateEnabled + } + + if logger != nil && logger.Enabled(context.Background(), slog.LevelDebug) { + config.LogLevel = aws.LogLevel(aws.LogDebugWithHTTPBody) + } + + return oam_v1.NewClient(logger, aws_oam.New(sess, setSTSCreds(sess, config, role))) +} + func setExternalID(ID string) func(p *stscreds.AssumeRoleProvider) { return func(p *stscreds.AssumeRoleProvider) { if ID != "" { diff --git a/pkg/clients/v2/factory.go b/pkg/clients/v2/factory.go index 6bc8d5a1c..11c35eabc 100644 --- a/pkg/clients/v2/factory.go +++ b/pkg/clients/v2/factory.go @@ -32,6 +32,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/databasemigrationservice" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/iam" + aws_oam "github.com/aws/aws-sdk-go-v2/service/oam" "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" "github.com/aws/aws-sdk-go-v2/service/shield" "github.com/aws/aws-sdk-go-v2/service/storagegateway" @@ -44,6 +45,8 @@ import ( account_v2 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/account/v2" cloudwatch_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" cloudwatch_v2 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch/v2" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" + oam_v2 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam/v2" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging" tagging_v2 "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging/v2" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/model" @@ -71,6 +74,7 @@ type cachedClients struct { cloudwatch cloudwatch_client.Client tagging tagging.Client account account.Client + oam oam.Client } // Ensure the struct properly implements the interface @@ -156,6 +160,42 @@ func NewFactory(logger *slog.Logger, jobsCfg model.JobsConfig, fips bool) (*Cach } } + // Ensure OAM region exists in cache when using linked account aliases (GetOAMClient is called with oamRegion) + if jobsCfg.OAMSinkIdentifier != "" && jobsCfg.OAMRegion != "" { + for _, discoveryJob := range jobsCfg.DiscoveryJobs { + if len(discoveryJob.IncludeLinkedAccounts) > 0 { + for _, role := range discoveryJob.Roles { + if _, ok := cache[role]; !ok { + cache[role] = map[awsRegion]*cachedClients{} + } + if _, exists := cache[role][jobsCfg.OAMRegion]; !exists { + regionConfig := awsConfigForRegion(role, &c, jobsCfg.OAMRegion, stsOptions) + cache[role][jobsCfg.OAMRegion] = &cachedClients{ + awsConfig: regionConfig, + onlyStatic: true, + } + } + } + } + } + for _, customNamespaceJob := range jobsCfg.CustomNamespaceJobs { + if len(customNamespaceJob.IncludeLinkedAccounts) > 0 { + for _, role := range customNamespaceJob.Roles { + if _, ok := cache[role]; !ok { + cache[role] = map[awsRegion]*cachedClients{} + } + if _, exists := cache[role][jobsCfg.OAMRegion]; !exists { + regionConfig := awsConfigForRegion(role, &c, jobsCfg.OAMRegion, stsOptions) + cache[role][jobsCfg.OAMRegion] = &cachedClients{ + awsConfig: regionConfig, + onlyStatic: true, + } + } + } + } + } + } + return &CachingFactory{ logger: logger, clients: cache, @@ -220,6 +260,19 @@ func (c *CachingFactory) GetAccountClient(region string, role model.Role) accoun return c.clients[role][region].account } +func (c *CachingFactory) GetOAMClient(region string, role model.Role) oam.Client { + if !c.refreshed.Load() { + c.mu.Lock() + defer c.mu.Unlock() + } + if client := c.clients[role][region].oam; client != nil { + return client + } + + c.clients[role][region].oam = oam_v2.NewClient(c.logger, c.createOAMClient(c.clients[role][region].awsConfig)) + return c.clients[role][region].oam +} + func (c *CachingFactory) Refresh() { if c.refreshed.Load() { return @@ -276,6 +329,7 @@ func (c *CachingFactory) Clear() { cache.cloudwatch = nil cache.account = nil cache.tagging = nil + cache.oam = nil } } @@ -431,6 +485,17 @@ func (c *CachingFactory) createIAMClient(awsConfig *aws.Config) *iam.Client { return iam.NewFromConfig(*awsConfig) } +func (c *CachingFactory) createOAMClient(awsConfig *aws.Config) *aws_oam.Client { + return aws_oam.NewFromConfig(*awsConfig, func(options *aws_oam.Options) { + if c.logger != nil && c.logger.Enabled(context.Background(), slog.LevelDebug) { + options.ClientLogMode = aws.LogRequestWithBody | aws.LogResponseWithBody + } + if c.endpointURLOverride != "" { + options.BaseEndpoint = aws.String(c.endpointURLOverride) + } + }) +} + func (c *CachingFactory) createShieldClient(awsConfig *aws.Config) *shield.Client { return shield.NewFromConfig(*awsConfig, func(options *shield.Options) { if c.logger != nil && c.logger.Enabled(context.Background(), slog.LevelDebug) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2ddff8267..e9e31eaea 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,11 +27,13 @@ import ( ) type ScrapeConf struct { - APIVersion string `yaml:"apiVersion"` - StsRegion string `yaml:"sts-region"` - Discovery Discovery `yaml:"discovery"` - Static []*Static `yaml:"static"` - CustomNamespace []*CustomNamespace `yaml:"customNamespace"` + APIVersion string `yaml:"apiVersion"` + StsRegion string `yaml:"sts-region"` + OAMSinkIdentifier string `yaml:"oamSinkIdentifier"` + OAMRegion string `yaml:"oamRegion"` + Discovery Discovery `yaml:"discovery"` + Static []*Static `yaml:"static"` + CustomNamespace []*CustomNamespace `yaml:"customNamespace"` } type Discovery struct { @@ -443,6 +445,8 @@ func (m *Metric) validateMetric(logger *slog.Logger, metricIdx int, parent strin func (c *ScrapeConf) toModelConfig() model.JobsConfig { jobsCfg := model.JobsConfig{} jobsCfg.StsRegion = c.StsRegion + jobsCfg.OAMSinkIdentifier = c.OAMSinkIdentifier + jobsCfg.OAMRegion = c.OAMRegion for _, discoveryJob := range c.Discovery.Jobs { svc := SupportedServices.GetService(discoveryJob.Type) diff --git a/pkg/exporter_enhancedmetrics_test.go b/pkg/exporter_enhancedmetrics_test.go index 5bc534109..384c7cbb6 100644 --- a/pkg/exporter_enhancedmetrics_test.go +++ b/pkg/exporter_enhancedmetrics_test.go @@ -17,6 +17,7 @@ import ( "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/account" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/internal/enhancedmetrics" enhancedmetricsDynamoDBService "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/internal/enhancedmetrics/service/dynamodb" @@ -55,6 +56,11 @@ func (m *mockFactoryForEnhancedMetrics) GetTaggingClient(string, model.Role, int return m.taggingClient } +// GetOAMClient implements clients.Factory +func (m *mockFactoryForEnhancedMetrics) GetOAMClient(string, model.Role) oam.Client { + return nil +} + // GetAWSRegionalConfig implements config.RegionalConfigProvider func (m *mockFactoryForEnhancedMetrics) GetAWSRegionalConfig(string, model.Role) *aws.Config { return m.awsConfig diff --git a/pkg/exporter_test.go b/pkg/exporter_test.go index 716ca2d26..8a877ceb3 100644 --- a/pkg/exporter_test.go +++ b/pkg/exporter_test.go @@ -28,6 +28,7 @@ import ( "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/account" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/config" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/model" @@ -52,6 +53,10 @@ func (f *mockFactory) GetAccountClient(_ string, _ model.Role) account.Client { return f.accountClient } +func (f *mockFactory) GetOAMClient(_ string, _ model.Role) oam.Client { + return nil +} + // mockAccountClient implements the account.Client interface type mockAccountClient struct { accountID string diff --git a/pkg/internal/enhancedmetrics/service_test.go b/pkg/internal/enhancedmetrics/service_test.go index c42af83aa..babad3c73 100644 --- a/pkg/internal/enhancedmetrics/service_test.go +++ b/pkg/internal/enhancedmetrics/service_test.go @@ -25,6 +25,7 @@ import ( "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/account" cloudwatch_client "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/cloudwatch" + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/tagging" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/internal/enhancedmetrics/config" "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/internal/enhancedmetrics/service" @@ -58,6 +59,10 @@ func (m *mockFactory) GetAccountClient(string, model.Role) account.Client { return nil } +func (m *mockFactory) GetOAMClient(string, model.Role) oam.Client { + return nil +} + // mockNonRegionalFactory is a mock that does NOT implement config.RegionalConfigProvider type mockNonRegionalFactory struct{} @@ -73,6 +78,10 @@ func (m *mockNonRegionalFactory) GetAccountClient(string, model.Role) account.Cl return nil } +func (m *mockNonRegionalFactory) GetOAMClient(string, model.Role) oam.Client { + return nil +} + // mockMetricsService is a mock implementation of service.EnhancedMetricsService type mockMetricsService struct { getMetricsCalled int diff --git a/pkg/job/custom.go b/pkg/job/custom.go index aab7f8de9..ba70b0aec 100644 --- a/pkg/job/custom.go +++ b/pkg/job/custom.go @@ -27,8 +27,9 @@ func runCustomNamespaceJob( job model.CustomNamespaceJob, clientCloudwatch cloudwatch.Client, gmdProcessor getMetricDataProcessor, + linkedAliasResolver *linkedAccountAliasResolver, ) []*model.CloudwatchData { - cloudwatchDatas := getMetricDataForQueriesForCustomNamespace(ctx, job, clientCloudwatch, logger) + cloudwatchDatas := getMetricDataForQueriesForCustomNamespace(ctx, job, clientCloudwatch, logger, linkedAliasResolver) if len(cloudwatchDatas) == 0 { logger.Debug("No metrics data found") return nil @@ -49,6 +50,7 @@ func getMetricDataForQueriesForCustomNamespace( customNamespaceJob model.CustomNamespaceJob, clientCloudwatch cloudwatch.Client, logger *slog.Logger, + linkedAliasResolver *linkedAccountAliasResolver, ) []*model.CloudwatchData { mux := &sync.Mutex{} var getMetricDatas []*model.CloudwatchData @@ -71,13 +73,18 @@ func getMetricDataForQueriesForCustomNamespace( continue } + linkedAccountAlias := "" + if linkedAliasResolver != nil && cwMetric.LinkedAccountID != "" { + linkedAccountAlias = linkedAliasResolver.Resolve(ctx, cwMetric.LinkedAccountID) + } for _, stat := range metric.Statistics { data = append(data, &model.CloudwatchData{ - MetricName: metric.Name, - ResourceName: customNamespaceJob.Name, - LinkedAccountID: cwMetric.LinkedAccountID, - Namespace: customNamespaceJob.Namespace, - Dimensions: cwMetric.Dimensions, + MetricName: metric.Name, + ResourceName: customNamespaceJob.Name, + LinkedAccountID: cwMetric.LinkedAccountID, + LinkedAccountAlias: linkedAccountAlias, + Namespace: customNamespaceJob.Namespace, + Dimensions: cwMetric.Dimensions, GetMetricDataProcessingParams: &model.GetMetricDataProcessingParams{ Period: metric.Period, Length: metric.Length, diff --git a/pkg/job/discovery.go b/pkg/job/discovery.go index 5a742ad17..660f9f71a 100644 --- a/pkg/job/discovery.go +++ b/pkg/job/discovery.go @@ -58,6 +58,7 @@ func runDiscoveryJob( gmdProcessor getMetricDataProcessor, enhancedMetricsService enhancedMetricsService, role model.Role, + linkedAliasResolver *linkedAccountAliasResolver, ) ([]*model.TaggedResource, []*model.CloudwatchData) { logger.Debug("Get tagged resources") @@ -76,7 +77,7 @@ func runDiscoveryJob( } svc := config.SupportedServices.GetService(job.Namespace) - metricData := getMetricDataForQueries(ctx, logger, job, svc, clientCloudwatch, resources) + metricData := getMetricDataForQueries(ctx, logger, job, svc, clientCloudwatch, resources, linkedAliasResolver) if len(metricData) > 0 && svc != nil { metricData, err = gmdProcessor.Run(ctx, svc.Namespace, metricData) @@ -127,6 +128,7 @@ func getMetricDataForQueries( svc *config.ServiceConfig, clientCloudwatch cloudwatch.Client, resources []*model.TaggedResource, + linkedAliasResolver *linkedAccountAliasResolver, ) []*model.CloudwatchData { mux := &sync.Mutex{} var getMetricDatas []*model.CloudwatchData @@ -150,7 +152,7 @@ func getMetricDataForQueries( defer wg.Done() err := clientCloudwatch.ListMetrics(ctx, svc.Namespace, metric, discoveryJob.IncludeLinkedAccounts, discoveryJob.RecentlyActiveOnly, func(page []*model.Metric) { - data := getFilteredMetricDatas(logger, discoveryJob.Namespace, discoveryJob.ExportedTagsOnMetrics, page, discoveryJob.DimensionNameRequirements, metric, assoc) + data := getFilteredMetricDatas(ctx, logger, discoveryJob.Namespace, discoveryJob.ExportedTagsOnMetrics, page, discoveryJob.DimensionNameRequirements, metric, assoc, linkedAliasResolver) mux.Lock() getMetricDatas = append(getMetricDatas, data...) @@ -174,6 +176,7 @@ func (ns nopAssociator) AssociateMetricToResource(_ *model.Metric) (*model.Tagge } func getFilteredMetricDatas( + ctx context.Context, logger *slog.Logger, namespace string, tagsOnMetrics []string, @@ -181,6 +184,7 @@ func getFilteredMetricDatas( dimensionNameList []string, m *model.MetricConfig, assoc resourceAssociator, + linkedAliasResolver *linkedAccountAliasResolver, ) []*model.CloudwatchData { getMetricsData := make([]*model.CloudwatchData, 0, len(metricsList)) for _, cwMetric := range metricsList { @@ -208,13 +212,18 @@ func getFilteredMetricDatas( } metricTags := resource.MetricTags(tagsOnMetrics) + linkedAccountAlias := "" + if linkedAliasResolver != nil && cwMetric.LinkedAccountID != "" { + linkedAccountAlias = linkedAliasResolver.Resolve(ctx, cwMetric.LinkedAccountID) + } for _, stat := range m.Statistics { getMetricsData = append(getMetricsData, &model.CloudwatchData{ - MetricName: m.Name, - ResourceName: resource.ARN, - LinkedAccountID: cwMetric.LinkedAccountID, - Namespace: namespace, - Dimensions: cwMetric.Dimensions, + MetricName: m.Name, + ResourceName: resource.ARN, + LinkedAccountID: cwMetric.LinkedAccountID, + LinkedAccountAlias: linkedAccountAlias, + Namespace: namespace, + Dimensions: cwMetric.Dimensions, GetMetricDataProcessingParams: &model.GetMetricDataProcessingParams{ Period: m.Period, Length: m.Length, diff --git a/pkg/job/discovery_test.go b/pkg/job/discovery_test.go index 725584e71..79d84c172 100644 --- a/pkg/job/discovery_test.go +++ b/pkg/job/discovery_test.go @@ -13,6 +13,7 @@ package job import ( + "context" "testing" "github.com/prometheus/common/promslog" @@ -544,7 +545,7 @@ func Test_getFilteredMetricDatas(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assoc := maxdimassociator.NewAssociator(promslog.NewNopLogger(), tt.args.dimensionRegexps, tt.args.resources) - metricDatas := getFilteredMetricDatas(promslog.NewNopLogger(), tt.args.namespace, tt.args.tagsOnMetrics, tt.args.metricsList, tt.args.dimensionNameRequirements, tt.args.m, assoc) + metricDatas := getFilteredMetricDatas(context.Background(), promslog.NewNopLogger(), tt.args.namespace, tt.args.tagsOnMetrics, tt.args.metricsList, tt.args.dimensionNameRequirements, tt.args.m, assoc, nil) if len(metricDatas) != len(tt.wantGetMetricsData) { t.Errorf("len(getFilteredMetricDatas()) = %v, want %v", len(metricDatas), len(tt.wantGetMetricsData)) } @@ -556,6 +557,7 @@ func Test_getFilteredMetricDatas(t *testing.T) { assert.ElementsMatch(t, want.Dimensions, got.Dimensions) assert.ElementsMatch(t, want.Tags, got.Tags) assert.Equal(t, want.LinkedAccountID, got.LinkedAccountID) + assert.Equal(t, want.LinkedAccountAlias, got.LinkedAccountAlias) assert.Equal(t, want.MetricMigrationParams, got.MetricMigrationParams) assert.Equal(t, want.GetMetricDataProcessingParams.Statistic, got.GetMetricDataProcessingParams.Statistic) assert.Equal(t, want.GetMetricDataProcessingParams.Length, got.GetMetricDataProcessingParams.Length) diff --git a/pkg/job/linked_account_alias.go b/pkg/job/linked_account_alias.go new file mode 100644 index 000000000..8857eb4a2 --- /dev/null +++ b/pkg/job/linked_account_alias.go @@ -0,0 +1,64 @@ +// Copyright 2024 The Prometheus Authors +// 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 job + +import ( + "context" + "log/slog" + "sync" + + "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/clients/oam" +) + +type linkedAccountAliasResolver struct { + logger *slog.Logger + oamClient oam.Client + sinkIdentifier string + aliases map[string]string + loaded bool + mu sync.Mutex +} + +func newLinkedAccountAliasResolver(logger *slog.Logger, oamClient oam.Client, sinkIdentifier string) *linkedAccountAliasResolver { + return &linkedAccountAliasResolver{ + logger: logger, + oamClient: oamClient, + sinkIdentifier: sinkIdentifier, + } +} + +func (r *linkedAccountAliasResolver) Resolve(ctx context.Context, accountID string) string { + if r == nil || accountID == "" { + return "" + } + + r.mu.Lock() + defer r.mu.Unlock() + + if !r.loaded { + r.loadAliases(ctx) + } + + return r.aliases[accountID] +} + +func (r *linkedAccountAliasResolver) loadAliases(ctx context.Context) { + aliases, err := r.oamClient.ListLinkedAccounts(ctx, r.sinkIdentifier) + if err != nil { + r.logger.Warn("Failed to list linked accounts from OAM", "err", err, "sink_identifier", r.sinkIdentifier) + r.aliases = map[string]string{} + } else { + r.aliases = aliases + } + r.loaded = true +} diff --git a/pkg/job/linked_account_alias_test.go b/pkg/job/linked_account_alias_test.go new file mode 100644 index 000000000..22f574a68 --- /dev/null +++ b/pkg/job/linked_account_alias_test.go @@ -0,0 +1,103 @@ +// Copyright 2024 The Prometheus Authors +// 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 job + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/require" +) + +type mockOAMClient struct { + accounts map[string]string + err error +} + +func (m *mockOAMClient) ListLinkedAccounts(_ context.Context, _ string) (map[string]string, error) { + if m.err != nil { + return nil, m.err + } + return m.accounts, nil +} + +func TestLinkedAccountAliasResolver_Resolve(t *testing.T) { + oamClient := &mockOAMClient{ + accounts: map[string]string{ + "111111111111": "dev-account", + "222222222222": "prod-account", + }, + } + resolver := newLinkedAccountAliasResolver(promslog.NewNopLogger(), oamClient, "arn:aws:oam:us-west-2:123456789012:sink/test") + ctx := context.Background() + + got := resolver.Resolve(ctx, "111111111111") + require.Equal(t, "dev-account", got) + + got = resolver.Resolve(ctx, "222222222222") + require.Equal(t, "prod-account", got) + + // Cached result + got = resolver.Resolve(ctx, "111111111111") + require.Equal(t, "dev-account", got) +} + +func TestLinkedAccountAliasResolver_ResolveEmpty(t *testing.T) { + oamClient := &mockOAMClient{ + accounts: map[string]string{}, + } + resolver := newLinkedAccountAliasResolver(promslog.NewNopLogger(), oamClient, "arn:aws:oam:us-west-2:123456789012:sink/test") + + // Empty account ID returns empty string + got := resolver.Resolve(context.Background(), "") + require.Equal(t, "", got) +} + +func TestLinkedAccountAliasResolver_ResolveError(t *testing.T) { + oamClient := &mockOAMClient{ + err: fmt.Errorf("OAM API error"), + } + resolver := newLinkedAccountAliasResolver(promslog.NewNopLogger(), oamClient, "arn:aws:oam:us-west-2:123456789012:sink/test") + + got := resolver.Resolve(context.Background(), "333333333333") + require.Equal(t, "", got) + + // Cached empty result on error + got = resolver.Resolve(context.Background(), "333333333333") + require.Equal(t, "", got) +} + +func TestLinkedAccountAliasResolver_ResolveUnknownAccount(t *testing.T) { + oamClient := &mockOAMClient{ + accounts: map[string]string{ + "111111111111": "dev-account", + }, + } + resolver := newLinkedAccountAliasResolver(promslog.NewNopLogger(), oamClient, "arn:aws:oam:us-west-2:123456789012:sink/test") + + // Known account + got := resolver.Resolve(context.Background(), "111111111111") + require.Equal(t, "dev-account", got) + + // Unknown account returns empty string + got = resolver.Resolve(context.Background(), "999999999999") + require.Equal(t, "", got) +} + +func TestLinkedAccountAliasResolver_NilResolver(t *testing.T) { + var resolver *linkedAccountAliasResolver + got := resolver.Resolve(context.Background(), "111111111111") + require.Equal(t, "", got) +} diff --git a/pkg/job/scrape.go b/pkg/job/scrape.go index dc2ebcf64..60142f719 100644 --- a/pkg/job/scrape.go +++ b/pkg/job/scrape.go @@ -83,6 +83,14 @@ func ScrapeAwsData( cloudwatchClient := factory.GetCloudwatchClient(region, role, cloudwatchConcurrency) gmdProcessor := getmetricdata.NewDefaultProcessor(logger, cloudwatchClient, metricsPerQuery, cloudwatchConcurrency.GetMetricData) + var linkedAliasResolver *linkedAccountAliasResolver + if len(discoveryJob.IncludeLinkedAccounts) > 0 && jobsCfg.OAMSinkIdentifier != "" { + oamRegion := jobsCfg.OAMRegion + if oamRegion == "" { + oamRegion = region + } + linkedAliasResolver = newLinkedAccountAliasResolver(jobLogger, factory.GetOAMClient(oamRegion, role), jobsCfg.OAMSinkIdentifier) + } resources, metrics := runDiscoveryJob( ctx, @@ -94,6 +102,7 @@ func ScrapeAwsData( gmdProcessor, enhancedMetricsService, role, + linkedAliasResolver, ) addDataToOutput := len(metrics) != 0 @@ -186,7 +195,15 @@ func ScrapeAwsData( cloudwatchClient := factory.GetCloudwatchClient(region, role, cloudwatchConcurrency) gmdProcessor := getmetricdata.NewDefaultProcessor(logger, cloudwatchClient, metricsPerQuery, cloudwatchConcurrency.GetMetricData) - metrics := runCustomNamespaceJob(ctx, jobLogger, customNamespaceJob, cloudwatchClient, gmdProcessor) + var linkedAliasResolver *linkedAccountAliasResolver + if len(customNamespaceJob.IncludeLinkedAccounts) > 0 && jobsCfg.OAMSinkIdentifier != "" { + oamRegion := jobsCfg.OAMRegion + if oamRegion == "" { + oamRegion = region + } + linkedAliasResolver = newLinkedAccountAliasResolver(jobLogger, factory.GetOAMClient(oamRegion, role), jobsCfg.OAMSinkIdentifier) + } + metrics := runCustomNamespaceJob(ctx, jobLogger, customNamespaceJob, cloudwatchClient, gmdProcessor, linkedAliasResolver) metricResult := model.CloudwatchMetricResult{ Context: &model.ScrapeContext{ Region: region, diff --git a/pkg/model/model.go b/pkg/model/model.go index b84bae583..eecb9df38 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -25,6 +25,8 @@ const ( type JobsConfig struct { StsRegion string + OAMSinkIdentifier string + OAMRegion string DiscoveryJobs []DiscoveryJob StaticJobs []StaticJob CustomNamespaceJobs []CustomNamespaceJob @@ -151,11 +153,12 @@ type CloudwatchData struct { // DiscoveryJob = Resource ARN associated with the metric or global when it could not be associated but shouldn't be dropped // StaticJob = Resource Name from static job config // CustomNamespace = Custom Namespace job name - ResourceName string - Namespace string - Tags []Tag - Dimensions []Dimension - LinkedAccountID string + ResourceName string + Namespace string + Tags []Tag + Dimensions []Dimension + LinkedAccountID string + LinkedAccountAlias string // GetMetricDataProcessingParams includes necessary fields to run GetMetricData GetMetricDataProcessingParams *GetMetricDataProcessingParams diff --git a/pkg/promutil/migrate.go b/pkg/promutil/migrate.go index d9a411866..573806e22 100644 --- a/pkg/promutil/migrate.go +++ b/pkg/promutil/migrate.go @@ -29,6 +29,14 @@ import ( var Percentile = regexp.MustCompile(`^p(\d{1,2}(\.\d{0,2})?|100)$`) +// formatAccountAlias lowercases the string and replaces spaces with "-" for use as the account_alias Prometheus label. +func formatAccountAlias(s string) string { + if s == "" { + return "" + } + return strings.ToLower(strings.ReplaceAll(s, " ", "-")) +} + func BuildMetricName(namespace, metricName, statistic string) string { sb := strings.Builder{} @@ -148,6 +156,9 @@ func BuildMetrics(results []model.CloudwatchMetricResult, labelsSnakeCase bool, // When querying linked accounts, override account_id with the metric's owning account if metric.LinkedAccountID != "" { promLabels["account_id"] = metric.LinkedAccountID + if metric.LinkedAccountAlias != "" { + promLabels["account_alias"] = formatAccountAlias(metric.LinkedAccountAlias) + } } observedMetricLabels = recordLabelsForMetric(name, promLabels, observedMetricLabels) @@ -301,7 +312,7 @@ func contextToLabels(context *model.ScrapeContext, labelsSnakeCase bool, logger labels["account_id"] = context.AccountID // If there's no account alias, omit adding an extra label in the series, it will work either way query wise if context.AccountAlias != "" { - labels["account_alias"] = context.AccountAlias + labels["account_alias"] = formatAccountAlias(context.AccountAlias) } for _, label := range context.CustomTags { diff --git a/pkg/promutil/migrate_test.go b/pkg/promutil/migrate_test.go index 272832ada..aaaf6c524 100644 --- a/pkg/promutil/migrate_test.go +++ b/pkg/promutil/migrate_test.go @@ -24,6 +24,25 @@ import ( "github.com/prometheus-community/yet-another-cloudwatch-exporter/pkg/model" ) +func TestFormatAccountAlias(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"", ""}, + {"Foo Bar", "foo-bar"}, + {"UPPER", "upper"}, + {"Already-Lower", "already-lower"}, + {"Multiple Spaces", "multiple---spaces"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got := formatAccountAlias(tt.in) + require.Equal(t, tt.want, got) + }) + } +} + func TestBuildNamespaceInfoMetrics(t *testing.T) { type testCase struct { name string @@ -1087,12 +1106,13 @@ func TestBuildMetrics(t *testing.T) { expectedErr: nil, }, { - name: "metric with LinkedAccountID overrides context account_id", + name: "metric with linked account overrides context account labels", data: []model.CloudwatchMetricResult{{ Context: &model.ScrapeContext{ - Region: "us-east-1", - AccountID: "123456789012", - CustomTags: nil, + Region: "us-east-1", + AccountID: "123456789012", + AccountAlias: "main-alias", + CustomTags: nil, }, Data: []*model.CloudwatchData{ { @@ -1101,8 +1121,9 @@ func TestBuildMetrics(t *testing.T) { NilToZero: false, AddCloudwatchTimestamp: false, }, - Namespace: "AWS/EC2", - LinkedAccountID: "999888777666", + Namespace: "AWS/EC2", + LinkedAccountID: "999888777666", + LinkedAccountAlias: "linked-alias", GetMetricDataResult: &model.GetMetricDataResult{ Statistic: "Average", DataPoints: []model.DataPoint{{Value: aws.Float64(50), Timestamp: ts}}, @@ -1125,6 +1146,7 @@ func TestBuildMetrics(t *testing.T) { Timestamp: nullTs, Labels: map[string]string{ "account_id": "999888777666", + "account_alias": "linked-alias", "name": "arn:aws:ec2:us-east-1:999888777666:instance/i-12345", "region": "us-east-1", "dimension_instance_id": "i-12345", @@ -1134,6 +1156,7 @@ func TestBuildMetrics(t *testing.T) { expectedLabels: map[string]model.LabelSet{ "aws_ec2_cpuutilization_average": { "account_id": {}, + "account_alias": {}, "name": {}, "region": {}, "dimension_instance_id": {}, From f3f4b9563e05bbb5ec50345bedf7928f83ca3e3f Mon Sep 17 00:00:00 2001 From: Pablo Osinaga Date: Tue, 10 Feb 2026 16:49:54 +0100 Subject: [PATCH 3/3] refactor(config): linked accounts configuration --- pkg/config/config.go | 33 ++++++++++++++----- pkg/config/config_test.go | 8 +++++ .../testdata/include_linked_accounts.ok.yml | 4 +++ pkg/job/scrape.go | 2 ++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index e9e31eaea..7ea3d3cad 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,13 +27,23 @@ import ( ) type ScrapeConf struct { - APIVersion string `yaml:"apiVersion"` - StsRegion string `yaml:"sts-region"` - OAMSinkIdentifier string `yaml:"oamSinkIdentifier"` - OAMRegion string `yaml:"oamRegion"` - Discovery Discovery `yaml:"discovery"` - Static []*Static `yaml:"static"` - CustomNamespace []*CustomNamespace `yaml:"customNamespace"` + APIVersion string `yaml:"apiVersion"` + StsRegion string `yaml:"sts-region"` + LinkedAccounts LinkedAccountsConfig `yaml:"linkedAccounts"` + OAMSinkIdentifier string `yaml:"oamSinkIdentifier"` // deprecated: use linkedAccounts.oam.sinkIdentifier + OAMRegion string `yaml:"oamRegion"` // deprecated: use linkedAccounts.oam.region + Discovery Discovery `yaml:"discovery"` + Static []*Static `yaml:"static"` + CustomNamespace []*CustomNamespace `yaml:"customNamespace"` +} + +type LinkedAccountsOAM struct { + SinkIdentifier string `yaml:"sinkIdentifier"` + Region string `yaml:"region"` +} + +type LinkedAccountsConfig struct { + OAM LinkedAccountsOAM `yaml:"oam"` } type Discovery struct { @@ -445,8 +455,13 @@ func (m *Metric) validateMetric(logger *slog.Logger, metricIdx int, parent strin func (c *ScrapeConf) toModelConfig() model.JobsConfig { jobsCfg := model.JobsConfig{} jobsCfg.StsRegion = c.StsRegion - jobsCfg.OAMSinkIdentifier = c.OAMSinkIdentifier - jobsCfg.OAMRegion = c.OAMRegion + if c.LinkedAccounts.OAM.SinkIdentifier != "" { + jobsCfg.OAMSinkIdentifier = c.LinkedAccounts.OAM.SinkIdentifier + jobsCfg.OAMRegion = c.LinkedAccounts.OAM.Region + } else { + jobsCfg.OAMSinkIdentifier = c.OAMSinkIdentifier + jobsCfg.OAMRegion = c.OAMRegion + } for _, discoveryJob := range c.Discovery.Jobs { svc := SupportedServices.GetService(discoveryJob.Type) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 69ca13abc..1d28fecc3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -175,3 +175,11 @@ func TestValidateConfigFailuresWhenUsingAsLibrary(t *testing.T) { }) } } + +func TestLinkedAccountsConfigMappedToModel(t *testing.T) { + var c ScrapeConf + jobsCfg, err := c.Load("testdata/include_linked_accounts.ok.yml", promslog.NewNopLogger()) + require.NoError(t, err) + require.Equal(t, "arn:aws:oam:eu-west-1:123456789012:sink/test", jobsCfg.OAMSinkIdentifier) + require.Equal(t, "eu-west-1", jobsCfg.OAMRegion) +} diff --git a/pkg/config/testdata/include_linked_accounts.ok.yml b/pkg/config/testdata/include_linked_accounts.ok.yml index c33225bf4..b3f320a36 100644 --- a/pkg/config/testdata/include_linked_accounts.ok.yml +++ b/pkg/config/testdata/include_linked_accounts.ok.yml @@ -1,5 +1,9 @@ apiVersion: v1alpha1 sts-region: eu-west-1 +linkedAccounts: + oam: + sinkIdentifier: "arn:aws:oam:eu-west-1:123456789012:sink/test" + region: "eu-west-1" discovery: jobs: - type: AWS/EC2 diff --git a/pkg/job/scrape.go b/pkg/job/scrape.go index 60142f719..039ef9d13 100644 --- a/pkg/job/scrape.go +++ b/pkg/job/scrape.go @@ -90,6 +90,7 @@ func ScrapeAwsData( oamRegion = region } linkedAliasResolver = newLinkedAccountAliasResolver(jobLogger, factory.GetOAMClient(oamRegion, role), jobsCfg.OAMSinkIdentifier) + jobLogger.Info("OAM linked account alias resolver created", "sink_identifier", jobsCfg.OAMSinkIdentifier, "oam_region", oamRegion) } resources, metrics := runDiscoveryJob( @@ -202,6 +203,7 @@ func ScrapeAwsData( oamRegion = region } linkedAliasResolver = newLinkedAccountAliasResolver(jobLogger, factory.GetOAMClient(oamRegion, role), jobsCfg.OAMSinkIdentifier) + jobLogger.Info("OAM linked account alias resolver created", "sink_identifier", jobsCfg.OAMSinkIdentifier, "oam_region", oamRegion) } metrics := runCustomNamespaceJob(ctx, jobLogger, customNamespaceJob, cloudwatchClient, gmdProcessor, linkedAliasResolver) metricResult := model.CloudwatchMetricResult{