master
go 842 lines 21.8 KB
Raw
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 }