| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package azure_monitor |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "slices" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" |
| 14 | "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/azure_monitor/azureprofiles" |
| 15 | ) |
| 16 | |
| 17 | type discoveryFetchResult struct { |
| 18 | Resources []resourceInfo |
| 19 | ByType map[string][]resourceInfo |
| 20 | UnsupportedTypes []string |
| 21 | QueryTagsColumnMissing bool |
| 22 | QueryTagsWrongShape bool |
| 23 | } |
| 24 | |
| 25 | type normalizedTagFilter struct { |
| 26 | Key string |
| 27 | Values []string |
| 28 | } |
| 29 | |
| 30 | type profileResourceMatcher struct { |
| 31 | profileName string |
| 32 | resourceType string |
| 33 | resourceGroups map[string]struct{} |
| 34 | regions map[string]struct{} |
| 35 | tagFilters []normalizedTagFilter |
| 36 | } |
| 37 | |
| 38 | func (c *Collector) refreshDiscovery(ctx context.Context, force bool) ([]resourceInfo, error) { |
| 39 | now := c.now() |
| 40 | if !force && !c.discovery.FetchedAt.IsZero() { |
| 41 | if c.Discovery.RefreshEvery == 0 || now.Before(c.discovery.ExpiresAt) { |
| 42 | return c.discovery.Resources, nil |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | fetched, err := c.fetchDiscovery(ctx) |
| 47 | if err != nil { |
| 48 | return nil, err |
| 49 | } |
| 50 | if len(fetched.UnsupportedTypes) > 0 { |
| 51 | c.Warningf("ignoring unsupported discovered resource types: %v", fetched.UnsupportedTypes) |
| 52 | } |
| 53 | |
| 54 | state := buildDiscoveryState(fetched.Resources, c.runtime, now, c.Discovery.RefreshEvery, c.discovery.FetchCounter+1, fetched) |
| 55 | if !equalResourceSlices(state.Resources, c.discovery.Resources) { |
| 56 | c.Infof("discovered %d resources: %v", len(state.Resources), state.Resources) |
| 57 | } |
| 58 | |
| 59 | c.discovery = state |
| 60 | c.warnDiscoveryScopeFallbacks(state, c.runtime) |
| 61 | return state.Resources, nil |
| 62 | } |
| 63 | |
| 64 | func buildDiscoveryState(resources []resourceInfo, runtime *collectorRuntime, now time.Time, refreshEvery int, fetchCounter uint64, fetched discoveryFetchResult) discoveryState { |
| 65 | filteredResources, _ := filterDiscoveryResourcesByTypes(resources, runtimeResourceTypes(runtime)) |
| 66 | scopeReport := applyWorkloadHostScopes(filteredResources, runtime) |
| 67 | return discoveryState{ |
| 68 | Resources: filteredResources, |
| 69 | ByType: indexResourcesByType(filteredResources), |
| 70 | ByProfile: filterDiscoveryResourcesByProfiles(filteredResources, runtime), |
| 71 | FetchedAt: now, |
| 72 | ExpiresAt: discoveryExpiresAt(now, refreshEvery), |
| 73 | FetchCounter: fetchCounter, |
| 74 | QueryTagsColumnMissing: fetched.QueryTagsColumnMissing, |
| 75 | QueryTagsWrongShape: fetched.QueryTagsWrongShape, |
| 76 | UnsafeWorkloadValues: scopeReport.unsafeValues, |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func discoveryExpiresAt(now time.Time, refreshEvery int) time.Time { |
| 81 | if refreshEvery == 0 { |
| 82 | return time.Time{} |
| 83 | } |
| 84 | return now.Add(secondsToDuration(refreshEvery)) |
| 85 | } |
| 86 | |
| 87 | func (c *Collector) fetchDiscovery(ctx context.Context) (discoveryFetchResult, error) { |
| 88 | switch stringsLowerTrim(c.Discovery.Mode) { |
| 89 | case discoveryModeQuery: |
| 90 | return discoverResourcesFromQuery( |
| 91 | ctx, |
| 92 | c.subscriptionIDs(), |
| 93 | c.Timeout.Duration(), |
| 94 | c.resourceGraph, |
| 95 | c.Discovery.ModeQuery.KQL, |
| 96 | c.supportedResourceTypes, |
| 97 | ) |
| 98 | default: |
| 99 | resources, byType, err := discoverResources( |
| 100 | ctx, |
| 101 | c.subscriptionIDs(), |
| 102 | c.Timeout.Duration(), |
| 103 | c.resourceGraph, |
| 104 | runtimeResourceTypes(c.runtime), |
| 105 | c.Discovery.ModeFilters, |
| 106 | ) |
| 107 | if err != nil { |
| 108 | return discoveryFetchResult{}, err |
| 109 | } |
| 110 | return discoveryFetchResult{Resources: resources, ByType: byType}, nil |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func runtimeResourceTypes(runtime *collectorRuntime) []string { |
| 115 | if runtime == nil || len(runtime.Profiles) == 0 { |
| 116 | return nil |
| 117 | } |
| 118 | |
| 119 | resourceTypes := make([]string, 0, len(runtime.Profiles)) |
| 120 | seenTypes := make(map[string]struct{}, len(runtime.Profiles)) |
| 121 | for _, p := range runtime.Profiles { |
| 122 | t := stringsTrim(p.ResourceType) |
| 123 | if t == "" { |
| 124 | continue |
| 125 | } |
| 126 | tLower := stringsLowerTrim(t) |
| 127 | if _, ok := seenTypes[tLower]; ok { |
| 128 | continue |
| 129 | } |
| 130 | seenTypes[tLower] = struct{}{} |
| 131 | resourceTypes = append(resourceTypes, t) |
| 132 | } |
| 133 | return resourceTypes |
| 134 | } |
| 135 | |
| 136 | func discoverResources(ctx context.Context, subscriptionIDs []string, timeout time.Duration, resourceGraph resourceGraphClient, resourceTypes []string, filters *ResourceFiltersConfig) ([]resourceInfo, map[string][]resourceInfo, error) { |
| 137 | if len(resourceTypes) == 0 { |
| 138 | return nil, map[string][]resourceInfo{}, nil |
| 139 | } |
| 140 | |
| 141 | query := buildDiscoveryQuery(resourceTypes, filters) |
| 142 | if query == "" { |
| 143 | return nil, nil, fmt.Errorf("failed to build resource discovery query") |
| 144 | } |
| 145 | |
| 146 | resourceGroupsFilter := normalizedFilterSet(nil) |
| 147 | regionsFilter := normalizedFilterSet(nil) |
| 148 | if filters != nil { |
| 149 | resourceGroupsFilter = normalizedFilterSet(filters.ResourceGroups) |
| 150 | regionsFilter = normalizedFilterSet(filters.Regions) |
| 151 | } |
| 152 | |
| 153 | result := make([]resourceInfo, 0, 256) |
| 154 | seenIDs := make(map[string]struct{}) |
| 155 | |
| 156 | var skipToken *string |
| 157 | for { |
| 158 | req := armResourceGraphQuery(subscriptionIDs, query, skipToken) |
| 159 | reqCtx, cancel := withOptionalTimeout(ctx, timeout) |
| 160 | resp, err := resourceGraph.Resources(reqCtx, req, nil) |
| 161 | cancel() |
| 162 | if err != nil { |
| 163 | return nil, nil, err |
| 164 | } |
| 165 | |
| 166 | rows, err := parseResourceGraphObjectArray(resp.Data) |
| 167 | if err != nil { |
| 168 | return nil, nil, err |
| 169 | } |
| 170 | |
| 171 | for _, row := range rows { |
| 172 | id := stringsTrim(asString(row["id"])) |
| 173 | if id == "" { |
| 174 | continue |
| 175 | } |
| 176 | idKey := stringsLowerTrim(id) |
| 177 | if _, ok := seenIDs[idKey]; ok { |
| 178 | continue |
| 179 | } |
| 180 | seenIDs[idKey] = struct{}{} |
| 181 | |
| 182 | rg := stringsTrim(asString(row["resourceGroup"])) |
| 183 | if len(resourceGroupsFilter) > 0 { |
| 184 | if _, ok := resourceGroupsFilter[stringsLowerTrim(rg)]; !ok { |
| 185 | continue |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | resourceType := stringsTrim(asString(row["type"])) |
| 190 | subscriptionID, ok := parseARMResourceID(id) |
| 191 | if resourceType == "" || !ok { |
| 192 | continue |
| 193 | } |
| 194 | |
| 195 | region := stringsLowerTrim(asString(row["location"])) |
| 196 | if region == "" { |
| 197 | region = "global" |
| 198 | } |
| 199 | if len(regionsFilter) > 0 { |
| 200 | if _, ok := regionsFilter[region]; !ok { |
| 201 | continue |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | result = append(result, resourceInfo{ |
| 206 | SubscriptionID: subscriptionID, |
| 207 | ID: id, |
| 208 | UID: hashShort(id), |
| 209 | Name: stringsTrim(asString(row["name"])), |
| 210 | Type: resourceType, |
| 211 | ResourceGroup: rg, |
| 212 | Region: region, |
| 213 | Tags: normalizeResourceTags(row["tags"]), |
| 214 | }) |
| 215 | } |
| 216 | |
| 217 | if resp.SkipToken == nil || stringsTrim(*resp.SkipToken) == "" { |
| 218 | break |
| 219 | } |
| 220 | token := stringsTrim(*resp.SkipToken) |
| 221 | skipToken = &token |
| 222 | } |
| 223 | |
| 224 | sortResourceInfos(result) |
| 225 | return result, indexResourcesByType(result), nil |
| 226 | } |
| 227 | |
| 228 | func discoverResourcesFromQuery(ctx context.Context, subscriptionIDs []string, timeout time.Duration, resourceGraph resourceGraphClient, kql string, supportedTypes map[string]struct{}) (discoveryFetchResult, error) { |
| 229 | query := stringsTrim(kql) |
| 230 | if query == "" { |
| 231 | return discoveryFetchResult{}, errors.New("custom discovery query is empty") |
| 232 | } |
| 233 | |
| 234 | result := make([]resourceInfo, 0, 256) |
| 235 | unsupported := make(map[string]struct{}) |
| 236 | seenIDs := make(map[string]struct{}) |
| 237 | var resultMissingTagsColumn, resultWrongTagsShape bool |
| 238 | |
| 239 | var skipToken *string |
| 240 | for { |
| 241 | req := armResourceGraphQuery(subscriptionIDs, query, skipToken) |
| 242 | reqCtx, cancel := withOptionalTimeout(ctx, timeout) |
| 243 | resp, err := resourceGraph.Resources(reqCtx, req, nil) |
| 244 | cancel() |
| 245 | if err != nil { |
| 246 | return discoveryFetchResult{}, err |
| 247 | } |
| 248 | |
| 249 | rows, err := parseResourceGraphObjectArray(resp.Data) |
| 250 | if err != nil { |
| 251 | return discoveryFetchResult{}, err |
| 252 | } |
| 253 | |
| 254 | for i, row := range rows { |
| 255 | resource, tagsShape, err := parseStrictQueryDiscoveryRow(row) |
| 256 | if err != nil { |
| 257 | return discoveryFetchResult{}, fmt.Errorf("query result row %d: %w", i, err) |
| 258 | } |
| 259 | switch tagsShape { |
| 260 | case queryTagsShapeAbsent: |
| 261 | resultMissingTagsColumn = true |
| 262 | case queryTagsShapeWrong: |
| 263 | resultWrongTagsShape = true |
| 264 | } |
| 265 | |
| 266 | idKey := stringsLowerTrim(resource.ID) |
| 267 | if _, ok := seenIDs[idKey]; ok { |
| 268 | return discoveryFetchResult{}, fmt.Errorf("query result contains duplicate id %q", resource.ID) |
| 269 | } |
| 270 | seenIDs[idKey] = struct{}{} |
| 271 | |
| 272 | result = append(result, resource) |
| 273 | typeKey := stringsLowerTrim(resource.Type) |
| 274 | if _, ok := supportedTypes[typeKey]; !ok { |
| 275 | unsupported[typeKey] = struct{}{} |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | if resp.SkipToken == nil || stringsTrim(*resp.SkipToken) == "" { |
| 280 | break |
| 281 | } |
| 282 | token := stringsTrim(*resp.SkipToken) |
| 283 | skipToken = &token |
| 284 | } |
| 285 | |
| 286 | sortResourceInfos(result) |
| 287 | |
| 288 | unsupportedTypes := make([]string, 0, len(unsupported)) |
| 289 | for resourceType := range unsupported { |
| 290 | unsupportedTypes = append(unsupportedTypes, resourceType) |
| 291 | } |
| 292 | slices.Sort(unsupportedTypes) |
| 293 | |
| 294 | return discoveryFetchResult{ |
| 295 | Resources: result, |
| 296 | ByType: indexResourcesByType(result), |
| 297 | UnsupportedTypes: unsupportedTypes, |
| 298 | QueryTagsColumnMissing: resultMissingTagsColumn, |
| 299 | QueryTagsWrongShape: resultWrongTagsShape, |
| 300 | }, nil |
| 301 | } |
| 302 | |
| 303 | func armResourceGraphQuery(subscriptionIDs []string, query string, skipToken *string) armresourcegraph.QueryRequest { |
| 304 | resultFormat := armresourcegraph.ResultFormatObjectArray |
| 305 | top := int32(1000) |
| 306 | options := &armresourcegraph.QueryRequestOptions{ |
| 307 | ResultFormat: &resultFormat, |
| 308 | Top: &top, |
| 309 | } |
| 310 | if skipToken != nil && stringsTrim(*skipToken) != "" { |
| 311 | token := stringsTrim(*skipToken) |
| 312 | options.SkipToken = &token |
| 313 | } |
| 314 | |
| 315 | subs := make([]*string, 0, len(subscriptionIDs)) |
| 316 | for _, subID := range subscriptionIDs { |
| 317 | subID = stringsTrim(subID) |
| 318 | if subID == "" { |
| 319 | continue |
| 320 | } |
| 321 | subscription := subID |
| 322 | subs = append(subs, &subscription) |
| 323 | } |
| 324 | |
| 325 | return armresourcegraph.QueryRequest{ |
| 326 | Query: &query, |
| 327 | Subscriptions: subs, |
| 328 | Options: options, |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func parseARMResourceID(resourceID string) (string, bool) { |
| 333 | resourceID = stringsTrim(resourceID) |
| 334 | if resourceID == "" || !strings.HasPrefix(resourceID, "/") { |
| 335 | return "", false |
| 336 | } |
| 337 | |
| 338 | parts := strings.Split(strings.Trim(resourceID, "/"), "/") |
| 339 | if len(parts) < 6 || len(parts)%2 != 0 { |
| 340 | return "", false |
| 341 | } |
| 342 | if !strings.EqualFold(parts[0], "subscriptions") || stringsTrim(parts[1]) == "" { |
| 343 | return "", false |
| 344 | } |
| 345 | |
| 346 | hasProviders := false |
| 347 | for i := 0; i+1 < len(parts); i += 2 { |
| 348 | if stringsTrim(parts[i]) == "" || stringsTrim(parts[i+1]) == "" { |
| 349 | return "", false |
| 350 | } |
| 351 | if strings.EqualFold(parts[i], "providers") { |
| 352 | hasProviders = true |
| 353 | } |
| 354 | } |
| 355 | if !hasProviders { |
| 356 | return "", false |
| 357 | } |
| 358 | |
| 359 | return stringsTrim(parts[1]), true |
| 360 | } |
| 361 | |
| 362 | func buildDiscoveryQuery(resourceTypes []string, filters *ResourceFiltersConfig) string { |
| 363 | if len(resourceTypes) == 0 { |
| 364 | return "" |
| 365 | } |
| 366 | |
| 367 | quotedTypes := make([]string, 0, len(resourceTypes)) |
| 368 | for _, rt := range resourceTypes { |
| 369 | rt = stringsTrim(rt) |
| 370 | if rt == "" { |
| 371 | continue |
| 372 | } |
| 373 | if !azureprofiles.IsValidResourceType(rt) { |
| 374 | continue |
| 375 | } |
| 376 | quotedTypes = append(quotedTypes, quoteKQLString(rt)) |
| 377 | } |
| 378 | if len(quotedTypes) == 0 { |
| 379 | return "" |
| 380 | } |
| 381 | |
| 382 | query := "resources | where type in~ (" + strings.Join(quotedTypes, ", ") + ")" |
| 383 | |
| 384 | if filters == nil { |
| 385 | return query + " | project id, name, type, resourceGroup, location, tags" |
| 386 | } |
| 387 | |
| 388 | if groups := normalizeFilterValues(filters.ResourceGroups); len(groups) > 0 { |
| 389 | quotedGroups := make([]string, 0, len(groups)) |
| 390 | for _, rg := range groups { |
| 391 | quotedGroups = append(quotedGroups, quoteKQLString(rg)) |
| 392 | } |
| 393 | query += " | where resourceGroup in~ (" + strings.Join(quotedGroups, ", ") + ")" |
| 394 | } |
| 395 | |
| 396 | if regions := normalizeFilterValues(filters.Regions); len(regions) > 0 { |
| 397 | quotedRegions := make([]string, 0, len(regions)) |
| 398 | for _, region := range regions { |
| 399 | quotedRegions = append(quotedRegions, quoteKQLString(region)) |
| 400 | } |
| 401 | query += " | where location in~ (" + strings.Join(quotedRegions, ", ") + ")" |
| 402 | } |
| 403 | |
| 404 | if tagFilters := normalizeTagFilters(filters.Tags); len(tagFilters) > 0 { |
| 405 | query += " | extend tagsBag = tags" |
| 406 | query += " | mv-expand bagexpansion=array tags" |
| 407 | query += " | where isnotempty(tags)" |
| 408 | query += " | extend tagKey = tostring(tags[0]), tagValue = tostring(tags[1])" |
| 409 | query += " | where " + buildTagPredicate(tagFilters) |
| 410 | query += " | summarize tags = take_any(tagsBag), matchedTagKeys = dcount(tolower(tagKey)) by id, name, type, resourceGroup, location" |
| 411 | query += fmt.Sprintf(" | where matchedTagKeys == %d", len(tagFilters)) |
| 412 | } |
| 413 | |
| 414 | return query + " | project id, name, type, resourceGroup, location, tags" |
| 415 | } |
| 416 | |
| 417 | func normalizeFilterValues(values []string) []string { |
| 418 | seen := make(map[string]struct{}, len(values)) |
| 419 | out := make([]string, 0, len(values)) |
| 420 | |
| 421 | for _, v := range values { |
| 422 | n := stringsLowerTrim(v) |
| 423 | if n == "" { |
| 424 | continue |
| 425 | } |
| 426 | if _, ok := seen[n]; ok { |
| 427 | continue |
| 428 | } |
| 429 | seen[n] = struct{}{} |
| 430 | out = append(out, n) |
| 431 | } |
| 432 | |
| 433 | slices.Sort(out) |
| 434 | return out |
| 435 | } |
| 436 | |
| 437 | func normalizeTagFilters(tags map[string][]string) []normalizedTagFilter { |
| 438 | if len(tags) == 0 { |
| 439 | return nil |
| 440 | } |
| 441 | |
| 442 | out := make([]normalizedTagFilter, 0, len(tags)) |
| 443 | for key, values := range tags { |
| 444 | normalizedKey := stringsLowerTrim(key) |
| 445 | if normalizedKey == "" { |
| 446 | continue |
| 447 | } |
| 448 | |
| 449 | seenValues := make(map[string]struct{}, len(values)) |
| 450 | normalizedValues := make([]string, 0, len(values)) |
| 451 | for _, value := range values { |
| 452 | trimmed := stringsTrim(value) |
| 453 | if trimmed == "" { |
| 454 | continue |
| 455 | } |
| 456 | if _, ok := seenValues[trimmed]; ok { |
| 457 | continue |
| 458 | } |
| 459 | seenValues[trimmed] = struct{}{} |
| 460 | normalizedValues = append(normalizedValues, trimmed) |
| 461 | } |
| 462 | if len(normalizedValues) == 0 { |
| 463 | continue |
| 464 | } |
| 465 | |
| 466 | slices.Sort(normalizedValues) |
| 467 | out = append(out, normalizedTagFilter{Key: normalizedKey, Values: normalizedValues}) |
| 468 | } |
| 469 | |
| 470 | slices.SortFunc(out, func(a, b normalizedTagFilter) int { |
| 471 | switch { |
| 472 | case a.Key < b.Key: |
| 473 | return -1 |
| 474 | case a.Key > b.Key: |
| 475 | return 1 |
| 476 | default: |
| 477 | return 0 |
| 478 | } |
| 479 | }) |
| 480 | return out |
| 481 | } |
| 482 | |
| 483 | func buildTagPredicate(filters []normalizedTagFilter) string { |
| 484 | clauses := make([]string, 0, len(filters)) |
| 485 | for _, filter := range filters { |
| 486 | keyClause := "tagKey =~ " + quoteKQLString(filter.Key) |
| 487 | valueClause := buildTagValueClause(filter.Values) |
| 488 | clauses = append(clauses, "("+keyClause+" and "+valueClause+")") |
| 489 | } |
| 490 | return strings.Join(clauses, " or ") |
| 491 | } |
| 492 | |
| 493 | func buildTagValueClause(values []string) string { |
| 494 | if len(values) == 1 { |
| 495 | return "tagValue == " + quoteKQLString(values[0]) |
| 496 | } |
| 497 | |
| 498 | quoted := make([]string, 0, len(values)) |
| 499 | for _, value := range values { |
| 500 | quoted = append(quoted, quoteKQLString(value)) |
| 501 | } |
| 502 | return "tagValue in (" + strings.Join(quoted, ", ") + ")" |
| 503 | } |
| 504 | |
| 505 | func normalizedFilterSet(values []string) map[string]struct{} { |
| 506 | if len(values) == 0 { |
| 507 | return nil |
| 508 | } |
| 509 | |
| 510 | set := make(map[string]struct{}, len(values)) |
| 511 | for _, value := range normalizeFilterValues(values) { |
| 512 | set[value] = struct{}{} |
| 513 | } |
| 514 | return set |
| 515 | } |
| 516 | |
| 517 | type queryTagsShape int |
| 518 | |
| 519 | const ( |
| 520 | queryTagsShapeAbsent queryTagsShape = iota |
| 521 | queryTagsShapePresentMap |
| 522 | queryTagsShapeWrong |
| 523 | ) |
| 524 | |
| 525 | func parseStrictQueryDiscoveryRow(row map[string]any) (resourceInfo, queryTagsShape, error) { |
| 526 | id, err := strictQueryStringColumn(row, "id") |
| 527 | if err != nil { |
| 528 | return resourceInfo{}, queryTagsShapeAbsent, err |
| 529 | } |
| 530 | subscriptionID, ok := parseARMResourceID(id) |
| 531 | if !ok { |
| 532 | return resourceInfo{}, queryTagsShapeAbsent, fmt.Errorf("invalid ARM resource id %q", id) |
| 533 | } |
| 534 | |
| 535 | name, err := strictQueryStringColumn(row, "name") |
| 536 | if err != nil { |
| 537 | return resourceInfo{}, queryTagsShapeAbsent, err |
| 538 | } |
| 539 | resourceType, err := strictQueryStringColumn(row, "type") |
| 540 | if err != nil { |
| 541 | return resourceInfo{}, queryTagsShapeAbsent, err |
| 542 | } |
| 543 | if resourceType == "" { |
| 544 | return resourceInfo{}, queryTagsShapeAbsent, errors.New("column 'type' must not be empty") |
| 545 | } |
| 546 | |
| 547 | resourceGroup, err := strictQueryStringColumn(row, "resourceGroup") |
| 548 | if err != nil { |
| 549 | return resourceInfo{}, queryTagsShapeAbsent, err |
| 550 | } |
| 551 | location, err := strictQueryStringColumn(row, "location") |
| 552 | if err != nil { |
| 553 | return resourceInfo{}, queryTagsShapeAbsent, err |
| 554 | } |
| 555 | region := stringsLowerTrim(location) |
| 556 | if region == "" { |
| 557 | region = "global" |
| 558 | } |
| 559 | tags, tagsShape := optionalQueryTagsColumn(row) |
| 560 | |
| 561 | return resourceInfo{ |
| 562 | SubscriptionID: subscriptionID, |
| 563 | ID: id, |
| 564 | UID: hashShort(id), |
| 565 | Name: name, |
| 566 | Type: resourceType, |
| 567 | ResourceGroup: resourceGroup, |
| 568 | Region: region, |
| 569 | Tags: tags, |
| 570 | }, tagsShape, nil |
| 571 | } |
| 572 | |
| 573 | func optionalQueryTagsColumn(row map[string]any) ([]resourceTag, queryTagsShape) { |
| 574 | value, ok := row["tags"] |
| 575 | if !ok { |
| 576 | return nil, queryTagsShapeAbsent |
| 577 | } |
| 578 | if value == nil { |
| 579 | return nil, queryTagsShapePresentMap |
| 580 | } |
| 581 | switch value.(type) { |
| 582 | case map[string]any, map[string]string: |
| 583 | return normalizeResourceTags(value), queryTagsShapePresentMap |
| 584 | default: |
| 585 | return nil, queryTagsShapeWrong |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | func strictQueryStringColumn(row map[string]any, column string) (string, error) { |
| 590 | value, ok := row[column] |
| 591 | if !ok { |
| 592 | return "", fmt.Errorf("missing required column %q", column) |
| 593 | } |
| 594 | |
| 595 | switch v := value.(type) { |
| 596 | case string: |
| 597 | return stringsTrim(v), nil |
| 598 | case fmt.Stringer: |
| 599 | return stringsTrim(v.String()), nil |
| 600 | default: |
| 601 | return "", fmt.Errorf("column %q must be a string", column) |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | func filterDiscoveryResourcesByTypes(resources []resourceInfo, allowedTypes []string) ([]resourceInfo, map[string][]resourceInfo) { |
| 606 | if len(resources) == 0 || len(allowedTypes) == 0 { |
| 607 | return nil, map[string][]resourceInfo{} |
| 608 | } |
| 609 | |
| 610 | allowed := make(map[string]struct{}, len(allowedTypes)) |
| 611 | for _, resourceType := range allowedTypes { |
| 612 | allowed[stringsLowerTrim(resourceType)] = struct{}{} |
| 613 | } |
| 614 | |
| 615 | filtered := make([]resourceInfo, 0, len(resources)) |
| 616 | for _, resource := range resources { |
| 617 | typeKey := stringsLowerTrim(resource.Type) |
| 618 | if _, ok := allowed[typeKey]; !ok { |
| 619 | continue |
| 620 | } |
| 621 | filtered = append(filtered, resource) |
| 622 | } |
| 623 | |
| 624 | return filtered, indexResourcesByType(filtered) |
| 625 | } |
| 626 | |
| 627 | func filterDiscoveryResourcesByProfiles(resources []resourceInfo, runtime *collectorRuntime) map[string][]resourceInfo { |
| 628 | if runtime == nil || len(runtime.Profiles) == 0 { |
| 629 | return map[string][]resourceInfo{} |
| 630 | } |
| 631 | |
| 632 | result := make(map[string][]resourceInfo, len(runtime.Profiles)) |
| 633 | for _, profile := range runtime.Profiles { |
| 634 | matcher := newProfileResourceMatcher(profile) |
| 635 | for _, resource := range resources { |
| 636 | if !matcher.matches(resource) { |
| 637 | continue |
| 638 | } |
| 639 | result[profile.Name] = append(result[profile.Name], resource) |
| 640 | } |
| 641 | } |
| 642 | return result |
| 643 | } |
| 644 | |
| 645 | func newProfileResourceMatcher(profile *profileRuntime) profileResourceMatcher { |
| 646 | matcher := profileResourceMatcher{ |
| 647 | resourceType: stringsLowerTrim(profile.ResourceType), |
| 648 | profileName: profile.Name, |
| 649 | } |
| 650 | if profile.Filters == nil { |
| 651 | return matcher |
| 652 | } |
| 653 | |
| 654 | matcher.resourceGroups = normalizedFilterSet(profile.Filters.ResourceGroups) |
| 655 | matcher.regions = normalizedFilterSet(profile.Filters.Regions) |
| 656 | matcher.tagFilters = normalizeTagFilters(profile.Filters.Tags) |
| 657 | return matcher |
| 658 | } |
| 659 | |
| 660 | func (m profileResourceMatcher) matches(resource resourceInfo) bool { |
| 661 | if stringsLowerTrim(resource.Type) != m.resourceType { |
| 662 | return false |
| 663 | } |
| 664 | if len(m.resourceGroups) > 0 { |
| 665 | if _, ok := m.resourceGroups[stringsLowerTrim(resource.ResourceGroup)]; !ok { |
| 666 | return false |
| 667 | } |
| 668 | } |
| 669 | if len(m.regions) > 0 { |
| 670 | if _, ok := m.regions[normalizeRegion(resource.Region)]; !ok { |
| 671 | return false |
| 672 | } |
| 673 | } |
| 674 | for _, tagFilter := range m.tagFilters { |
| 675 | if !resourceMatchesTag(resource, tagFilter) { |
| 676 | return false |
| 677 | } |
| 678 | } |
| 679 | return true |
| 680 | } |
| 681 | |
| 682 | func resourceMatchesTag(resource resourceInfo, filter normalizedTagFilter) bool { |
| 683 | for _, tag := range resource.Tags { |
| 684 | if tag.Key != filter.Key { |
| 685 | continue |
| 686 | } |
| 687 | return slices.Contains(filter.Values, tag.Value) |
| 688 | } |
| 689 | return false |
| 690 | } |
| 691 | |
| 692 | func indexResourcesByType(resources []resourceInfo) map[string][]resourceInfo { |
| 693 | result := make(map[string][]resourceInfo) |
| 694 | for _, resource := range resources { |
| 695 | key := stringsLowerTrim(resource.Type) |
| 696 | result[key] = append(result[key], resource) |
| 697 | } |
| 698 | return result |
| 699 | } |
| 700 | |
| 701 | func catalogResourceTypeSet(catalog azureprofiles.Catalog) map[string]struct{} { |
| 702 | types := make(map[string]struct{}) |
| 703 | for _, resourceType := range catalog.ResourceTypes() { |
| 704 | types[stringsLowerTrim(resourceType)] = struct{}{} |
| 705 | } |
| 706 | return types |
| 707 | } |
| 708 | |
| 709 | func quoteKQLString(v string) string { |
| 710 | return "'" + strings.ReplaceAll(v, "'", "''") + "'" |
| 711 | } |
| 712 | |
| 713 | func parseResourceGraphObjectArray(v any) ([]map[string]any, error) { |
| 714 | rows, ok := v.([]any) |
| 715 | if !ok { |
| 716 | return nil, fmt.Errorf("unexpected resource graph result format: %T", v) |
| 717 | } |
| 718 | out := make([]map[string]any, 0, len(rows)) |
| 719 | for _, row := range rows { |
| 720 | m, ok := row.(map[string]any) |
| 721 | if !ok { |
| 722 | continue |
| 723 | } |
| 724 | out = append(out, m) |
| 725 | } |
| 726 | return out, nil |
| 727 | } |
| 728 | |
| 729 | func asString(v any) string { |
| 730 | switch x := v.(type) { |
| 731 | case string: |
| 732 | return x |
| 733 | case fmt.Stringer: |
| 734 | return x.String() |
| 735 | default: |
| 736 | return "" |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | func normalizeResourceTags(v any) []resourceTag { |
| 741 | rawTags, ok := v.(map[string]any) |
| 742 | if !ok { |
| 743 | if typed, ok := v.(map[string]string); ok { |
| 744 | rawTags = make(map[string]any, len(typed)) |
| 745 | for key, value := range typed { |
| 746 | rawTags[key] = value |
| 747 | } |
| 748 | } else { |
| 749 | return nil |
| 750 | } |
| 751 | } |
| 752 | |
| 753 | tags := make([]resourceTag, 0, len(rawTags)) |
| 754 | for key, value := range rawTags { |
| 755 | tagKey := stringsLowerTrim(key) |
| 756 | if tagKey == "" { |
| 757 | continue |
| 758 | } |
| 759 | tags = append(tags, resourceTag{ |
| 760 | Key: tagKey, |
| 761 | Value: normalizeResourceTagValue(value), |
| 762 | }) |
| 763 | } |
| 764 | |
| 765 | slices.SortFunc(tags, func(a, b resourceTag) int { |
| 766 | switch { |
| 767 | case a.Key < b.Key: |
| 768 | return -1 |
| 769 | case a.Key > b.Key: |
| 770 | return 1 |
| 771 | case a.Value < b.Value: |
| 772 | return -1 |
| 773 | case a.Value > b.Value: |
| 774 | return 1 |
| 775 | default: |
| 776 | return 0 |
| 777 | } |
| 778 | }) |
| 779 | return tags |
| 780 | } |
| 781 | |
| 782 | func normalizeResourceTagValue(v any) string { |
| 783 | switch x := v.(type) { |
| 784 | case nil: |
| 785 | return "" |
| 786 | case string: |
| 787 | return stringsTrim(x) |
| 788 | case fmt.Stringer: |
| 789 | return stringsTrim(x.String()) |
| 790 | default: |
| 791 | return stringsTrim(fmt.Sprint(x)) |
| 792 | } |
| 793 | } |
| 794 | |
| 795 | func sortResourceInfos(resources []resourceInfo) { |
| 796 | slices.SortFunc(resources, func(a, b resourceInfo) int { |
| 797 | switch { |
| 798 | case stringsLowerTrim(a.ID) < stringsLowerTrim(b.ID): |
| 799 | return -1 |
| 800 | case stringsLowerTrim(a.ID) > stringsLowerTrim(b.ID): |
| 801 | return 1 |
| 802 | default: |
| 803 | return 0 |
| 804 | } |
| 805 | }) |
| 806 | } |
| 807 | |
| 808 | func equalResourceSlices(a, b []resourceInfo) bool { |
| 809 | if len(a) != len(b) { |
| 810 | return false |
| 811 | } |
| 812 | for i := range a { |
| 813 | if !equalResourceInfo(a[i], b[i]) { |
| 814 | return false |
| 815 | } |
| 816 | } |
| 817 | return true |
| 818 | } |
| 819 | |
| 820 | func equalResourceInfo(a, b resourceInfo) bool { |
| 821 | return a.SubscriptionID == b.SubscriptionID && |
| 822 | a.ID == b.ID && |
| 823 | a.UID == b.UID && |
| 824 | a.Name == b.Name && |
| 825 | a.Type == b.Type && |
| 826 | a.ResourceGroup == b.ResourceGroup && |
| 827 | a.Region == b.Region && |
| 828 | a.HostScope.ScopeKey == b.HostScope.ScopeKey && |
| 829 | equalResourceTags(a.Tags, b.Tags) |
| 830 | } |
| 831 | |
| 832 | func equalResourceTags(a, b []resourceTag) bool { |
| 833 | if len(a) != len(b) { |
| 834 | return false |
| 835 | } |
| 836 | for i := range a { |
| 837 | if a[i] != b[i] { |
| 838 | return false |
| 839 | } |
| 840 | } |
| 841 | return true |
| 842 | } |