diff --git a/README.md b/README.md index 22d977f..5daa591 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ This exporter needs `Reader` permissions on subscription level. | `azurerm_publicip_info` | Portscan | Azure PublicIP information | | `azurerm_publicip_portscan_status` | Portscan | Status of scanned ports (finished scan, elapsed time, updated timestamp) | | `azurerm_publicip_portscan_port` | Portscan | List of opened ports per IP | +| `azurerm_advisor_recommendation` | Advisor | Azure Advisor recommendation | ### ResourceTags handling @@ -114,4 +115,3 @@ see [armclient tracing documentation](https://github.com/webdevops/go-common/blo ### Caching see [prometheus collector cache documentation](https://github.com/webdevops/go-common/blob/main/prometheus/README.md#caching) - diff --git a/config/config.go b/config/config.go index 4ab5c6d..6c4c21c 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ type ( General CollectorBase `json:"general"` Resource CollectorBase `json:"resource"` Quota CollectorBase `json:"quota"` + Advisor CollectorAdvisor `json:"advisor"` Defender CollectorBase `json:"defender"` ResourceHealth CollectorResourceHealth `json:"resourceHealth"` Iam CollectorBase `json:"iam"` diff --git a/config/config_advisor.go b/config/config_advisor.go new file mode 100644 index 0000000..ca44776 --- /dev/null +++ b/config/config_advisor.go @@ -0,0 +1,10 @@ +package config + +type ( + CollectorAdvisor struct { + *CollectorBase `yaml:",inline"` + + ProblemMaxLength int `json:"problemMaxLength"` + SolutionMaxLength int `json:"solutionMaxLength"` + } +) diff --git a/default.yaml b/default.yaml index a856d7e..9a9e662 100644 --- a/default.yaml +++ b/default.yaml @@ -13,6 +13,8 @@ collectors: quota: {} + advisor: {} + defender: {} resourceHealth: {} diff --git a/example.yaml b/example.yaml index 76e85bc..c8e97ae 100644 --- a/example.yaml +++ b/example.yaml @@ -34,6 +34,13 @@ collectors: quota: scrapeTime: 5m + # Azure Advisor recommendations + advisor: + scrapeTime: 5m + # Optional: truncate problem/solution text to avoid excessively long labels + problemMaxLength: 0 # 0 = no truncation + solutionMaxLength: 0 # 0 = no truncation + # Defender (security) metrics # score, recommendations, ... defender: diff --git a/main.go b/main.go index 9c9272c..71dfebc 100644 --- a/main.go +++ b/main.go @@ -268,6 +268,21 @@ func initMetricCollector() { logger.With(zap.String("collector", collectorName)).Infof("collector disabled") } + collectorName = "advisor" + if Config.Collectors.Advisor.IsEnabled() { + c := collector.New(collectorName, &MetricsCollectorAzureRmAdvisor{}, logger) + c.SetScapeTime(*Config.Collectors.Advisor.ScrapeTime) + c.SetCache( + Opts.GetCachePath(collectorName+".json"), + collector.BuildCacheTag(cacheTag, Config.Azure, Config.Collectors.Advisor), + ) + if err := c.Start(); err != nil { + logger.Fatal(err.Error()) + } + } else { + logger.With(zap.String("collector", collectorName)).Infof("collector disabled") + } + collectorName = "defender" if Config.Collectors.Defender.IsEnabled() { c := collector.New(collectorName, &MetricsCollectorAzureRmDefender{}, logger) diff --git a/metrics_azurerm_advisor.go b/metrics_azurerm_advisor.go new file mode 100644 index 0000000..22711f2 --- /dev/null +++ b/metrics_azurerm_advisor.go @@ -0,0 +1,140 @@ +package main + +import ( + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/advisor/armadvisor" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" + "github.com/prometheus/client_golang/prometheus" + "github.com/webdevops/go-common/prometheus/collector" + "github.com/webdevops/go-common/utils/to" + "go.uber.org/zap" +) + +type MetricsCollectorAzureRmAdvisor struct { + collector.Processor + + prometheus struct { + advisorRecommendation *prometheus.GaugeVec + } +} + +func (m *MetricsCollectorAzureRmAdvisor) Setup(collector *collector.Collector) { + m.Processor.Setup(collector) + + m.prometheus.advisorRecommendation = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "azurerm_advisor_recommendation", + Help: "Azure Advisor recommendation", + }, + []string{ + "recommendationID", + "resourceID", + "resourceType", + "category", + "impact", + "risk", + "recommendationSubCategory", + "problem", + "solution", + }, + ) + m.Collector.RegisterMetricList("advisorRecommendation", m.prometheus.advisorRecommendation, true) +} + +func (m *MetricsCollectorAzureRmAdvisor) Reset() {} + +func (m *MetricsCollectorAzureRmAdvisor) Collect(callback chan<- func()) { + err := AzureSubscriptionsIterator.ForEachAsync(m.Logger(), func(subscription *armsubscriptions.Subscription, logger *zap.SugaredLogger) { + m.collectAzureAdvisorRecommendations(subscription, logger) + }) + if err != nil { + m.Logger().Panic(err) + } +} + +func (m *MetricsCollectorAzureRmAdvisor) collectAzureAdvisorRecommendations(subscription *armsubscriptions.Subscription, logger *zap.SugaredLogger) { + client, err := armadvisor.NewRecommendationsClient(*subscription.SubscriptionID, AzureClient.GetCred(), AzureClient.NewArmClientOptions()) + if err != nil { + logger.Panic(err) + } + + // Generate recommendations first (async operation) + // Note: This is a fire-and-forget operation. The generation is asynchronous, + // so newly generated recommendations will be available in the next scrape cycle. + _, err = client.Generate(m.Context(), nil) + if err != nil { + logger.Warnf("failed to generate recommendations for subscription %s: %v", to.StringLower(subscription.SubscriptionID), err) + } + + recommendationMetrics := m.Collector.GetMetricList("advisorRecommendation") + + pager := client.NewListPager(nil) + for pager.More() { + result, err := pager.NextPage(m.Context()) + if err != nil { + logger.Panic(err) + } + + for _, recommendation := range result.Value { + category := "" + if recommendation.Properties.Category != nil { + category = strings.ToLower(string(*recommendation.Properties.Category)) + } + + risk := "" + if recommendation.Properties.Risk != nil { + risk = strings.ToLower(string(*recommendation.Properties.Risk)) + } + + impact := "" + if recommendation.Properties.Impact != nil { + impact = strings.ToLower(string(*recommendation.Properties.Impact)) + } + + problem := "" + solution := "" + if recommendation.Properties.ShortDescription != nil { + problem = to.String(recommendation.Properties.ShortDescription.Problem) + solution = to.String(recommendation.Properties.ShortDescription.Solution) + } + + // Truncate problem and solution if configured + if Config.Collectors.Advisor.ProblemMaxLength > 0 { + problem = truncateStrings(problem, Config.Collectors.Advisor.ProblemMaxLength, "...") + } + if Config.Collectors.Advisor.SolutionMaxLength > 0 { + solution = truncateStrings(solution, Config.Collectors.Advisor.SolutionMaxLength, "...") + } + + recommendationSubCategory := "" + if recommendation.Properties.ExtendedProperties != nil { + if subCat, ok := recommendation.Properties.ExtendedProperties["recommendationSubCategory"]; ok && subCat != nil { + recommendationSubCategory = to.String(subCat) + } + } + + resourceID := "" + if recommendation.Properties.ResourceMetadata != nil && recommendation.Properties.ResourceMetadata.ResourceID != nil { + resourceID = to.String(recommendation.Properties.ResourceMetadata.ResourceID) + } + + resourceType := to.StringLower(recommendation.Properties.ImpactedField) + + recommendationID := to.StringLower(recommendation.Name) + + infoLabels := prometheus.Labels{ + "recommendationID": recommendationID, + "resourceID": resourceID, + "resourceType": resourceType, + "category": category, + "impact": impact, + "risk": risk, + "recommendationSubCategory": recommendationSubCategory, + "problem": problem, + "solution": solution, + } + recommendationMetrics.Add(infoLabels, 1) + } + } +}