diff --git a/docs/how-to/030_azure-source.md b/docs/how-to/030_azure-source.md index 15125ce..7cc1466 100644 --- a/docs/how-to/030_azure-source.md +++ b/docs/how-to/030_azure-source.md @@ -82,6 +82,94 @@ For a more in-depth explanation refer to [Naming rules and restrictions for Azur Use `.name` wherever available to display casing matters, its availability is dependant on the specific resource APIs. The spelling Azure reported, if needed, is written to the source logs at the `Debug` level whenever it differs from the normalised value. +## Resource sub-types + +Some Azure resource types describe more than one thing: a `Microsoft.Web/sites` resource is an App +Service site, but when its `kind` carries the `functionapp` token it is also a Function App. + +For these types the Azure source produces, out of one Azure resource, both the item of the resource +itself and one or more **sub-type** items, each described by its own mapping file and related to the +item of the resource it was derived from. +A sub-type is always additive: the item of the Azure resource is produced exactly as it was before, +and the sub-type item is created next to it together with a relationship pointing at it. + +### How a sub-type mapping is dispatched + +The `type` of a sub-type mapping file is an **internal dispatch key**, not an Azure provider type: + +- it is never sent to Azure and never used to build a resource graph query +- it is never matched against the resource type of an event, because only the source can decide to + emit it +- its `extra.apiVersion` is never read, since the resource is always retrieved with the `apiVersion` + of its parent type. It is kept in the file only for symmetry with every other Azure mapping + +Which Azure type produces which sub-type is hardcoded in the source, together with the check deciding +whether a retrieved resource must produce it. Declaring a sub-type therefore takes both a new mapping +file and a change to that hardcoded dictionary: a sub-type can never be introduced by configuration +alone. + +### The sub-types shipped with ibdm + +| Azure type | Sub-type mapping | Produced when | +| --- | --- | --- | +| `Microsoft.Web/sites` | `docs/mappings/azure/websites_functionapps.yaml`, `type: functionapps` | the `kind` of the site carries the `functionapp` token | + +`kind` is a comma separated list of tokens, such as `app`, `app,linux` or `functionapp,linux`, and +the tokens are compared one by one: a site whose kind is `myfunctionapp` is not a Function App. +A site without a usable `kind` produces no sub-type and nothing fails. + +The mapping creates an `functionapps` item and, through its `extra` section, a `dependency` +relationship from that item to the `websites` item of the same site. + +Both mapping files must be loaded for the sub-type to be produced. Loading +`docs/mappings/azure/websites.yaml` alone reproduces exactly the behaviour the source had before +sub-types existed, deletion included. Loading `websites_functionapps.yaml` alone can instead never +produce anything, so the source logs a warning when it starts and carries on. + +`ibdm sync azure` and `ibdm run azure` behave identically, because the check runs on the payload the +Azure APIs returned and is indifferent to which of them retrieved it. To adopt a sub-type on an +already imported subscription load both mapping files and run `ibdm sync azure` once: every site that +already exists gets its sub-type item and its relationship. + +### Deleting a resource that has sub-types + +`Microsoft.Resources.ResourceDeleteSuccess` carries only the id of the deleted resource. Its `kind` +is gone and no API can return it any more, so at deletion time the check cannot run: the source +deletes the item of the resource **and the item of every sub-type its type can produce**, whether or +not that resource ever produced it. + +For a `Microsoft.Web/sites` resource with both mapping files loaded, three deletions reach the +catalog: + +| deleted | why | +| --- | --- | +| the `websites` item | the resource itself | +| the `functionapps` item | the only sub-type configured for its type | +| the relationship of the `functionapps` item | its `deletePolicy` is `cascade` | + +A deletion addressed to a sub-type item the resource never produced is inert: the catalog publish +reports no per item outcome, so nothing fails and nothing is left behind. The identifier of a +sub-type item also lives in its own namespace, `functionapps-` for the Function Apps, +so such a deletion can only ever name the sub-type item of that very resource. + +Removing a sub-type mapping file is not the reverse operation: the items it already published stop +being updated and stop being deleted together with their resource, so they have to be removed by +hand. + +### Authoring a sub-type mapping + +- Declare `syncable: true`. Keeping a sub-type key out of the resource graph queries is the job of + the hardcoded dictionary, not of `syncable`, and `syncable: false` would only risk confining the + sub-type to `ibdm run azure` +- Build the identifier of the item, and the identifier of every `deletePolicy: "cascade"` extra, out + of `.id` alone. A deletion payload carries only `id` and `type`, so a template reading any other + field fails to render and that deletion is lost +- Give the sub-type item its own item family, so that its identifiers can never collide with the ones + of another mapping, and make sure the item type definition for that family exists in the catalog +- Treat the payload as read only. A sub-type receives a shallow copy of the payload of its parent, so + writing into a nested value, such as `properties` or `tags`, would be seen by every other item + produced out of the same resource + ## Authentication The source is using the [`DefaultAzureCredential` chain of authentication] so you can setup diff --git a/docs/mappings/azure/websites_functionapps.yaml b/docs/mappings/azure/websites_functionapps.yaml new file mode 100644 index 0000000..5158253 --- /dev/null +++ b/docs/mappings/azure/websites_functionapps.yaml @@ -0,0 +1,43 @@ +apiVersion: azure.mia-platform.eu/v1 +itemFamily: functionapps +type: functionapps +extra: + apiVersion: "2025-03-01" +syncable: true +mappings: + identifier: |- + {{ printf "functionapps-%s" .id | sha256sum }} + metadata: + title: |- + {{ printf "Function App - %s" .name }} + spec: + name: "{{ .name }}" + id: "{{ .id }}" + location: "{{ .location | lower }}" + state: "{{ .properties.state | lower }}" + kind: "{{ .kind }}" + enabled: "{{ .properties.enabled }}" + availabilityState: "{{ .properties.availabilityState | lower }}" + defaultHostname: "{{ .properties.defaultHostName }}" + serverFarmId: "{{ .properties.serverFarmId }}" + tags: |- + {{ $tags := (get "tags" . "") -}} + {{- if $tags -}} + {{- $tags | toJSON -}} + {{- else -}} + {{- object | toJSON -}} + {{- end }} + extra: + - apiVersion: mia-platform.eu/v1 + itemFamily: relationships + deletePolicy: "cascade" + identifier: |- + {{ $src := printf "urn:mia-platform-catalog:azure.mia-platform.eu:v1:FunctionApp:%s" (printf "functionapps-%s" .id | sha256sum) -}} + {{- $type := "urn:mia-platform-catalog:mia-platform.eu:v1:RelationshipType:dependency.mia-platform.eu" -}} + {{- $tgt := printf "urn:mia-platform-catalog:azure.mia-platform.eu:v1:WebSite:%s" (printf "%s" .id | sha256sum) -}} + {{- printf "%s-%s-%s" $src $type $tgt | sha256sum }} + sourceRef: |- + urn:mia-platform-catalog:azure.mia-platform.eu:v1:FunctionApp:{{ printf "functionapps-%s" .id | sha256sum }} + targetRef: |- + urn:mia-platform-catalog:azure.mia-platform.eu:v1:WebSite:{{ (printf "%s" .id | sha256sum) }} + typeRef: "urn:mia-platform-catalog:mia-platform.eu:v1:RelationshipType:dependency.mia-platform.eu" diff --git a/internal/source/azure/azure.go b/internal/source/azure/azure.go index 7b43d84..c833133 100644 --- a/internal/source/azure/azure.go +++ b/internal/source/azure/azure.go @@ -98,6 +98,7 @@ func (s *Source) StartSyncProcess(ctx context.Context, typesToFilter map[string] if err := s.validateForSync(); err != nil { return handleError(err) } + warnOrphanSubTypes(logger, typesToFilter) client, err := s.azureGraphClient() if err != nil { @@ -111,61 +112,67 @@ func (s *Source) StartSyncProcess(ctx context.Context, typesToFilter map[string] }) for resType := range typesToFilter { - var query *string - switch resType { - case arm.ResourceGroupResourceType.String(): - graphResourceType := arm.SubscriptionResourceType.String() + "/resourceGroups" - query = to.Ptr(fmt.Sprintf(resourceContainerGraphQueryTemplate, graphResourceType)) - case arm.SubscriptionResourceType.String(): - query = to.Ptr(fmt.Sprintf(resourceContainerGraphQueryTemplate, resType)) - default: - query = to.Ptr(fmt.Sprintf(resourceGraphQueryTemplate, resType)) + // a sub-type is emitted while handling its parent resource, and its type key is an internal + // dispatch key: querying Azure for it would only ask for a type that does not exist. + if isSubTypeKey(resType) { + logger.Debug("skipping sub-type mapping, it is emitted with its parent type", "type", resType) + continue } - queryRequest := armresourcegraph.QueryRequest{ - Subscriptions: []*string{to.Ptr(s.SubscriptionID)}, - Query: query, + if err := s.syncResourceType(ctx, client, resType, typesToFilter, dataChannel); err != nil { + // handleError swallows the cancellation, so a stopped sync process is not a failure + return handleError(err) } + } - for { - timestamp := timeProvider() - response, err := client.Resources(ctx, queryRequest, nil) + s.syncContext.Swap(nil) + return nil +} - switch { - case errors.Is(err, context.Canceled): - logger.Debug("stopping sync process due to context cancellation") - return nil - case err != nil: - return handleError(err) - } +// syncResourceType pages through every resource of resType the Resource Graph returns and emits +// the item of each one of them, together with the ones of the sub-types they additionally produce. +func (s *Source) syncResourceType(ctx context.Context, client *armresourcegraph.Client, resType string, typesToFilter map[string]source.Extra, dataChannel chan<- source.Data) error { + logger := logger.FromContext(ctx).WithName(logName) + queryRequest := armresourcegraph.QueryRequest{ + Subscriptions: []*string{to.Ptr(s.SubscriptionID)}, + Query: resourceGraphQuery(resType), + } + + for { + timestamp := timeProvider() + response, err := client.Resources(ctx, queryRequest, nil) + + switch { + case errors.Is(err, context.Canceled): + logger.Debug("stopping sync process due to context cancellation") + return nil + case err != nil: + return err + } - if data, ok := response.Data.([]any); ok { - for _, item := range data { - if values, ok := item.(map[string]any); ok { - normalizeResourceValues(logger, values, resType) - dataChannel <- source.Data{ - Type: resType, - Operation: source.DataOperationUpsert, - Time: timestamp, - Values: values, - } - } else { - // something very wrong is going on, print an error and continue - logger.Debug("retrieve data item is not a valid map") + if data, ok := response.Data.([]any); ok { + for _, item := range data { + if values, ok := item.(map[string]any); ok { + normalizeResourceValues(logger, values, resType) + for _, resourceData := range resourceDataToEmit(resType, values, typesToFilter, source.DataOperationUpsert, timestamp) { + dataChannel <- resourceData } + } else { + // something very wrong is going on, print an error and continue + logger.Debug("retrieve data item is not a valid map") } - } else { - // something very wrong is going on, print an error and continue - logger.Debug("response data is not a valid type") } + } else { + // something very wrong is going on, print an error and continue + logger.Debug("response data is not a valid type") + } - if response.ResultTruncated == nil || *response.ResultTruncated == armresourcegraph.ResultTruncatedFalse { - break - } + if response.ResultTruncated == nil || *response.ResultTruncated == armresourcegraph.ResultTruncatedFalse { + break + } - queryRequest.Options = &armresourcegraph.QueryRequestOptions{ - SkipToken: response.SkipToken, - } + queryRequest.Options = &armresourcegraph.QueryRequestOptions{ + SkipToken: response.SkipToken, } } @@ -173,12 +180,27 @@ func (s *Source) StartSyncProcess(ctx context.Context, typesToFilter map[string] return nil } +// resourceGraphQuery returns the Resource Graph query retrieving every resource of resType, taken +// from the container table for the types that live in it. +func resourceGraphQuery(resType string) *string { + switch resType { + case arm.ResourceGroupResourceType.String(): + graphResourceType := arm.SubscriptionResourceType.String() + "/resourceGroups" + return to.Ptr(fmt.Sprintf(resourceContainerGraphQueryTemplate, graphResourceType)) + case arm.SubscriptionResourceType.String(): + return to.Ptr(fmt.Sprintf(resourceContainerGraphQueryTemplate, resType)) + default: + return to.Ptr(fmt.Sprintf(resourceGraphQueryTemplate, resType)) + } +} + // StartEventStream implement source.EventSource. func (s *Source) StartEventStream(ctx context.Context, typesToFilter map[string]source.Extra, dataChannel chan<- source.Data) error { logger := logger.FromContext(ctx).WithName(logName) if err := s.validateForEventStream(); err != nil { return handleError(err) } + warnOrphanSubTypes(logger, typesToFilter) client, err := s.azureClient() if err != nil { @@ -206,7 +228,9 @@ func (s *Source) StartEventStream(ctx context.Context, typesToFilter map[string] } func partitionEventHandler(client *armresources.Client, typesToFilter map[string]source.Extra, dataChannel chan<- source.Data) eventHandler { - typesSlice := slices.Sorted(maps.Keys(typesToFilter)) + // a sub-type type key is an internal dispatch key and can never be the type of an event + // subject, so it is left out of the set the subject type is resolved against. + typesSlice := slices.DeleteFunc(slices.Sorted(maps.Keys(typesToFilter)), isSubTypeKey) return func(ctx context.Context, receivedData *azeventhubs.ReceivedEventData) { logger := logger.FromContext(ctx).WithName(logName) @@ -258,21 +282,17 @@ func partitionEventHandler(client *armresources.Client, typesToFilter map[string } normalizeResourceValues(logger, values, resourceType) - dataChannel <- source.Data{ - Type: resourceType, - Operation: source.DataOperationUpsert, - Time: *envelope.Time, - Values: values, + for _, resourceData := range resourceDataToEmit(resourceType, values, typesToFilter, source.DataOperationUpsert, *envelope.Time) { + dataChannel <- resourceData } case azsystemevents.TypeResourceDeleteSuccess: logger.Trace("deleting resource", "resourceType", resourceType) + // the event carries only the resource id, so no sub-type check can run here and a + // delete is emitted for every configured sub-type of the resource type. values := map[string]any{idKey: resID.String()} normalizeResourceValues(logger, values, resourceType) - dataChannel <- source.Data{ - Type: resourceType, - Operation: source.DataOperationDelete, - Time: *envelope.Time, - Values: values, + for _, resourceData := range resourceDataToEmit(resourceType, values, typesToFilter, source.DataOperationDelete, *envelope.Time) { + dataChannel <- resourceData } default: logger.Trace("skipping resource", "resourceType", resourceType, "eventType", envelope.Type, "apiVersion", apiVersion) diff --git a/internal/source/azure/azure_stream_test.go b/internal/source/azure/azure_stream_test.go index 6cdbd37..fa8aa0c 100644 --- a/internal/source/azure/azure_stream_test.go +++ b/internal/source/azure/azure_stream_test.go @@ -182,6 +182,140 @@ func TestPartitionEventHandler(t *testing.T) { }, }, }, + "a site carrying the functionapp kind emits its item and the one of its sub-type": { + contextFunc: func(tb testing.TB) (context.Context, context.CancelFunc) { + tb.Helper() + return context.WithTimeout(tb.Context(), 1*time.Second) + }, + typesToFilter: map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, + azureData: &azeventhubs.ReceivedEventData{ + EventData: azeventhubs.EventData{ + Body: eventDataFunctionAppWriteBody, + }, + }, + expectedData: []source.Data{ + { + Type: websitesType, + Operation: source.DataOperationUpsert, + Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Values: streamedFunctionAppValues(), + }, + { + // the sub-type carries the payload of its parent, whose type stays the Azure + // provider type: a sub-type key is a dispatch key and never reaches the item + Type: functionAppsType, + Operation: source.DataOperationUpsert, + Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Values: streamedFunctionAppValues(), + }, + }, + }, + "a site not carrying the functionapp kind emits its item alone": { + contextFunc: func(tb testing.TB) (context.Context, context.CancelFunc) { + tb.Helper() + return context.WithTimeout(tb.Context(), 1*time.Second) + }, + typesToFilter: map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, + azureData: &azeventhubs.ReceivedEventData{ + EventData: azeventhubs.EventData{ + Body: eventDataWebsiteWriteBody, + }, + }, + expectedData: []source.Data{ + { + Type: websitesType, + Operation: source.DataOperationUpsert, + Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Values: map[string]any{ + "id": normalizedWebsiteID, + "kind": webAppKindValue, + "type": websitesType, + }, + }, + }, + }, + "deleting a site broadcasts to every configured sub-type": { + contextFunc: func(tb testing.TB) (context.Context, context.CancelFunc) { + tb.Helper() + return context.WithTimeout(tb.Context(), 1*time.Second) + }, + typesToFilter: map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, + azureData: &azeventhubs.ReceivedEventData{ + EventData: azeventhubs.EventData{ + Body: eventDataFunctionAppDeleteBody, + }, + }, + // the event carries only the resource id, so the kind check cannot run and the sub-type + // is deleted whether or not the site ever produced it + expectedData: []source.Data{ + { + Type: websitesType, + Operation: source.DataOperationDelete, + Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Values: map[string]any{ + "id": normalizedFunctionAppID, + "type": websitesType, + }, + }, + { + Type: functionAppsType, + Operation: source.DataOperationDelete, + Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Values: map[string]any{ + "id": normalizedFunctionAppID, + "type": websitesType, + }, + }, + }, + }, + "deleting a site without the sub-type mapping loaded keeps the previous behaviour": { + contextFunc: func(tb testing.TB) (context.Context, context.CancelFunc) { + tb.Helper() + return context.WithTimeout(tb.Context(), 1*time.Second) + }, + typesToFilter: map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + }, + azureData: &azeventhubs.ReceivedEventData{ + EventData: azeventhubs.EventData{ + Body: eventDataFunctionAppDeleteBody, + }, + }, + expectedData: []source.Data{ + { + Type: websitesType, + Operation: source.DataOperationDelete, + Time: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + Values: map[string]any{ + "id": normalizedFunctionAppID, + "type": websitesType, + }, + }, + }, + }, + "a sub-type mapping loaded without its parent produces nothing": { + contextFunc: func(tb testing.TB) (context.Context, context.CancelFunc) { + tb.Helper() + return context.WithTimeout(tb.Context(), 1*time.Second) + }, + typesToFilter: map[string]source.Extra{ + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, + azureData: &azeventhubs.ReceivedEventData{ + EventData: azeventhubs.EventData{ + Body: eventDataFunctionAppWriteBody, + }, + }, + }, "delete with non canonical subject casing": { contextFunc: func(tb testing.TB) (context.Context, context.CancelFunc) { tb.Helper() @@ -295,6 +429,26 @@ func handleResourcesGetByIDRequest(tb testing.TB, resourceID, apiVersion string) }, } return resp, nil + case functionAppGetByIDPath: + assert.Equal(tb, websitesAPIVersion, apiVersion) + resp = &armresources.ClientGetByIDResponse{ + GenericResource: armresources.GenericResource{ + ID: to.Ptr(azureFunctionAppID), + Kind: to.Ptr(functionAppKindValue), + Type: to.Ptr(websitesType), + }, + } + return resp, nil + case websiteGetByIDPath: + assert.Equal(tb, websitesAPIVersion, apiVersion) + resp = &armresources.ClientGetByIDResponse{ + GenericResource: armresources.GenericResource{ + ID: to.Ptr(azureWebsiteID), + Kind: to.Ptr(webAppKindValue), + Type: to.Ptr(websitesType), + }, + } + return resp, nil } return nil, nil @@ -309,8 +463,25 @@ const ( // the fake actually receives. managedClusterGetByIDPath = "subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.ContainerService/managedClusters/my-cluster" lowerTypeManagedClusterGetByIDPath = "subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/microsoft.containerservice/managedclusters/my-cluster" + + // the same keys for the two App Service sites: their subjects already spell the resource group + // literal in camelCase, so the fake receives azureFunctionAppID and azureWebsiteID without the + // leading slash. + functionAppGetByIDPath = "subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-function" + websiteGetByIDPath = "subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-site" ) +// streamedFunctionAppValues returns the payload the event handler emits for the App Service site +// carrying the functionapp kind token. It is built on every call because a sub-type receives its +// own copy of the payload of its parent. +func streamedFunctionAppValues() map[string]any { + return map[string]any{ + "id": normalizedFunctionAppID, + "kind": functionAppKindValue, + "type": websitesType, + } +} + var ( // eventDataManagedClusterWriteBody carries a lowercase resourcegroups literal and a canonically // cased provider and type. @@ -348,6 +519,40 @@ var ( "time": "2020-01-01T00:00:00.0000000Z" }]`) + // eventDataFunctionAppWriteBody and eventDataWebsiteWriteBody import the two App Service sites, + // only the first of which carries the functionapp kind token in the body the resource provider + // answers with. + eventDataFunctionAppWriteBody = json.RawMessage(`[ + { + "id": "00000000-0000-0000-0000-000000000000", + "source": "/subscriptions/00000000-0000-0000-0000-000000000000", + "specversion": "1.0", + "type": "Microsoft.Resources.ResourceWriteSuccess", + "subject": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-function", + "time": "2020-01-01T00:00:00.0000000Z" + }]`) + + eventDataWebsiteWriteBody = json.RawMessage(`[ + { + "id": "00000000-0000-0000-0000-000000000000", + "source": "/subscriptions/00000000-0000-0000-0000-000000000000", + "specversion": "1.0", + "type": "Microsoft.Resources.ResourceWriteSuccess", + "subject": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-site", + "time": "2020-01-01T00:00:00.0000000Z" + }]`) + + // eventDataFunctionAppDeleteBody carries only the resource id, as every delete event does. + eventDataFunctionAppDeleteBody = json.RawMessage(`[ + { + "id": "00000000-0000-0000-0000-000000000000", + "source": "/subscriptions/00000000-0000-0000-0000-000000000000", + "specversion": "1.0", + "type": "Microsoft.Resources.ResourceDeleteSuccess", + "subject": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-function", + "time": "2020-01-01T00:00:00.0000000Z" + }]`) + eventDataResourcesTestBody = json.RawMessage(`[ { "id": "00000000-0000-0000-0000-000000000000", diff --git a/internal/source/azure/azure_sync_test.go b/internal/source/azure/azure_sync_test.go index c481230..7038942 100644 --- a/internal/source/azure/azure_sync_test.go +++ b/internal/source/azure/azure_sync_test.go @@ -246,6 +246,53 @@ func TestStartSyncProcess(t *testing.T) { }, }, }, + "websites with the sub-type mapping loaded": { + typesToFilter: map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, + expectedData: []source.Data{ + { + Type: websitesType, + Operation: source.DataOperationUpsert, + Time: testTime, + Values: syncedFunctionAppValues(), + }, + { + // the sub-type carries the payload of its parent, whose type stays the Azure + // provider type: a sub-type key is a dispatch key and never reaches the item + Type: functionAppsType, + Operation: source.DataOperationUpsert, + Time: testTime, + Values: syncedFunctionAppValues(), + }, + { + Type: websitesType, + Operation: source.DataOperationUpsert, + Time: testTime, + Values: syncedWebsiteValues(), + }, + }, + }, + "websites without the sub-type mapping loaded keep the previous behaviour": { + typesToFilter: map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + }, + expectedData: []source.Data{ + { + Type: websitesType, + Operation: source.DataOperationUpsert, + Time: testTime, + Values: syncedFunctionAppValues(), + }, + { + Type: websitesType, + Operation: source.DataOperationUpsert, + Time: testTime, + Values: syncedWebsiteValues(), + }, + }, + }, "unusable ids are emitted unchanged": { typesToFilter: map[string]source.Extra{ "Microsoft.Resources/malformedResources": nil, @@ -338,6 +385,62 @@ func TestStartSyncProcess(t *testing.T) { } } +// syncedFunctionAppValues and syncedWebsiteValues return the payloads the sync process emits for +// the two App Service sites of resourceGraphWebsitesResponse. They are built on every call because +// a sub-type receives its own copy of the payload of its parent. +func syncedFunctionAppValues() map[string]any { + return map[string]any{ + "id": normalizedFunctionAppID, + "kind": functionAppKindValue, + "name": "my-function", + "type": websitesType, + } +} + +func syncedWebsiteValues() map[string]any { + return map[string]any{ + "id": normalizedWebsiteID, + "kind": webAppKindValue, + "name": "my-site", + "type": websitesType, + } +} + +// TestStartSyncProcessEmitsSubTypesAfterTheirParent checks the emission order the table driven test +// cannot assert, because it compares the collected data as a set. +func TestStartSyncProcessEmitsSubTypesAfterTheirParent(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), 1*time.Second) + defer cancel() + + azureSource := &Source{ + config: config{ + SubscriptionID: testSubscriptionID, + clientOptions: &arm.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Transport: fakeResourceGraphTransport(t), + }, + }, + azureCredentials: &fakeazcore.TokenCredential{}, + }, + } + + dataChannel := make(chan source.Data, 10) + require.NoError(t, azureSource.StartSyncProcess(ctx, map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, dataChannel)) + close(dataChannel) + + emittedTypes := make([]string, 0, 3) + for data := range dataChannel { + emittedTypes = append(emittedTypes, data.Type) + } + + assert.Equal(t, []string{websitesType, functionAppsType, websitesType}, emittedTypes) +} + func TestCancelledSyncProcess(t *testing.T) { t.Parallel() @@ -490,6 +593,18 @@ func handleResourceGraphQueryRequest(t *testing.T, query armresourcegraph.QueryR SkipToken: nil, }, }, nil + // the query is built from the Azure provider type alone: there is deliberately no branch for + // the functionapps sub-type key, so a query issued for it fails the test on the default case + case fmt.Sprintf(resourceGraphQueryTemplate, websitesType): + return &armresourcegraph.ClientResourcesResponse{ + QueryResponse: armresourcegraph.QueryResponse{ + TotalRecords: to.Ptr(int64(2)), + Data: resourceGraphWebsitesResponse, + ResultTruncated: to.Ptr(armresourcegraph.ResultTruncatedFalse), + Count: to.Ptr(int64(2)), + SkipToken: nil, + }, + }, nil case fmt.Sprintf(resourceGraphQueryTemplate, "Microsoft.Resources/malformedResources"): return &armresourcegraph.ClientResourcesResponse{ QueryResponse: armresourcegraph.QueryResponse{ @@ -523,6 +638,24 @@ var ( }, } + // resourceGraphWebsitesResponse holds one App Service site carrying the functionapp kind token + // and one that does not, so that one query exercises both the emission of a sub-type and its + // absence. + resourceGraphWebsitesResponse = []any{ + map[string]any{ + "id": azureFunctionAppID, + "kind": functionAppKindValue, + "name": "my-function", + "type": "microsoft.web/sites", + }, + map[string]any{ + "id": azureWebsiteID, + "kind": webAppKindValue, + "name": "my-site", + "type": "microsoft.web/sites", + }, + } + // resourceGraphMalformedResponse holds rows whose id cannot be normalized: the items must still // be emitted with their original value. resourceGraphMalformedResponse = []any{ diff --git a/internal/source/azure/helper_test.go b/internal/source/azure/helper_test.go new file mode 100644 index 0000000..3cacdcd --- /dev/null +++ b/internal/source/azure/helper_test.go @@ -0,0 +1,24 @@ +// Copyright Mia srl +// SPDX-License-Identifier: AGPL-3.0-only or Commercial + +package azure + +// Fixtures of the two App Service sites the sub-type tests import, shared by the sync, the stream +// and the dictionary tests because the sub-type emission runs on the retrieved payload whichever +// path retrieved it: my-function carries the functionapp kind token and my-site does not. +const ( + // websitesAPIVersion is the api-version the website mappings declare. + websitesAPIVersion = "2025-03-01" + + // functionAppKindValue and webAppKindValue are two kind values Azure returns for an App Service + // site: only the first one carries the functionapp token. + functionAppKindValue = "functionapp,linux" + webAppKindValue = "app,linux" + + // the ids as Azure spells them, with a camelCase resourceGroups literal and a canonically cased + // provider and type, and as the source normalizes them. + azureFunctionAppID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-function" + normalizedFunctionAppID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/my-rg/providers/microsoft.web/sites/my-function" + azureWebsiteID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Web/sites/my-site" + normalizedWebsiteID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/my-rg/providers/microsoft.web/sites/my-site" +) diff --git a/internal/source/azure/subtypes.go b/internal/source/azure/subtypes.go new file mode 100644 index 0000000..7cf2dc6 --- /dev/null +++ b/internal/source/azure/subtypes.go @@ -0,0 +1,214 @@ +// Copyright Mia srl +// SPDX-License-Identifier: AGPL-3.0-only or Commercial + +package azure + +import ( + "maps" + "slices" + "strings" + "time" + + "github.com/mia-platform/ibdm/internal/logger" + "github.com/mia-platform/ibdm/internal/source" +) + +const ( + // websitesType is the Azure provider type of the App Service sites. + websitesType = "Microsoft.Web/sites" + + // functionAppsType is the type key of the Function App sub-type mapping. It is an internal + // dispatch key and not an Azure provider type: it is never sent to Azure and it is never used + // to build a Resource Graph query. + functionAppsType = "functionapps" + + // kindKey is the payload key holding the kind of an Azure resource. + kindKey = "kind" + + // functionAppKind is the kind token marking an App Service site as a Function App. + functionAppKind = "functionapp" + + // kindSeparator separates the tokens of a kind payload value. + kindSeparator = "," +) + +// subType couples the type key of a sub-type mapping with the predicate deciding whether a parent +// resource payload must also produce it. Keeping the two together makes it impossible to declare a +// sub-type without the check guarding it. +type subType struct { + // typeKey is the type declared by the mapping file of the sub-type. + typeKey string + + // matches reports whether values, the retrieved payload of the parent resource, must also + // produce this sub-type. It is never consulted on a delete, where the payload carries only the + // resource id. + matches func(values map[string]any) bool +} + +// subTypes maps an Azure provider type to the sub-types it can additionally produce. A sub-type is +// a specialised item, described by its own mapping file and related to the item of the resource it +// was derived from, so declaring one requires an entry here and can never be done by configuration +// alone. +// +// The order of a list is the order its sub-types are emitted in. +var subTypes = map[string][]subType{ + websitesType: { + {typeKey: functionAppsType, matches: isFunctionApp}, + }, +} + +var ( + // subTypesByParent indexes subTypes by lowercased parent type so that a lookup ignores the + // letter case the mapping file used for the Azure provider type, consistently with + // configuredResourceType. + subTypesByParent = indexSubTypesByParent() + + // subTypeKeys holds every type key declared in subTypes. Deriving it from the dictionary keeps + // the exclusion of the sub-type keys from the Azure queries in step with their emission. + subTypeKeys = collectSubTypeKeys() +) + +// indexSubTypesByParent builds the case insensitive index of subTypes. +func indexSubTypesByParent() map[string][]subType { + index := make(map[string][]subType, len(subTypes)) + for parentType, candidates := range subTypes { + index[strings.ToLower(parentType)] = candidates + } + + return index +} + +// collectSubTypeKeys builds the set of every type key declared in subTypes. +func collectSubTypeKeys() map[string]struct{} { + keys := make(map[string]struct{}, len(subTypes)) + for _, candidates := range subTypes { + for _, candidate := range candidates { + keys[candidate.typeKey] = struct{}{} + } + } + + return keys +} + +// isSubTypeKey reports whether typeKey is the type key of a sub-type mapping. Such a key is an +// internal dispatch key: it must never be used to build a Resource Graph query, nor be matched +// against the resource type carried by the subject of an event. +func isSubTypeKey(typeKey string) bool { + _, found := subTypeKeys[typeKey] + return found +} + +// subTypesFor returns the sub-types resourceType can additionally produce, ignoring its letter case. +func subTypesFor(resourceType string) []subType { + return subTypesByParent[strings.ToLower(resourceType)] +} + +// isFunctionApp reports whether the kind of a resource carries the functionapp token. +// +// Azure spells kind as a comma separated list of tokens, such as "app", "app,linux" or +// "functionapp,linux,container", so the tokens are compared one by one and a kind such as +// "myfunctionapp" does not qualify. A missing, empty or non string kind reports false, so that a +// payload without the discriminant produces the item of the resource alone instead of failing. +func isFunctionApp(values map[string]any) bool { + kind, ok := values[kindKey].(string) + if !ok { + return false + } + + for token := range strings.SplitSeq(kind, kindSeparator) { + if strings.EqualFold(strings.TrimSpace(token), functionAppKind) { + return true + } + } + + return false +} + +// subTypesToEmit returns the type keys of the sub-types a resource of resourceType must +// additionally produce, in the order the dictionary declares them. +// +// On an upsert every predicate is evaluated against values, the payload retrieved from Azure. On a +// delete the payload carries only the resource id, so no predicate can run and every configured +// sub-type is returned: deleting a sub-type item that was never created is inert, because the +// Catalog publish reports no per item outcome. +// +// A sub-type whose mapping file is not loaded is never returned, so a deployment configuring no +// sub-type mapping behaves exactly as one running against an empty dictionary. +func subTypesToEmit(resourceType string, values map[string]any, configured map[string]source.Extra, isDelete bool) []string { + candidates := subTypesFor(resourceType) + if len(candidates) == 0 { + return nil + } + + keys := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if _, found := configured[candidate.typeKey]; !found { + continue + } + + if !isDelete && !candidate.matches(values) { + continue + } + + keys = append(keys, candidate.typeKey) + } + + return keys +} + +// resourceDataToEmit builds every source.Data a retrieved resource must produce: the item of the +// resource itself first, so that it exists before the sub-types relate to it, then one item for +// every sub-type resourceType additionally produces. +// +// Every predicate runs and every payload is built before the caller sends the first value, because +// the pipeline starts reading a value as soon as it is sent: cloning after a send would race the +// mapper. +// +// The payload of a sub-type is a shallow copy of the one of its parent, because the mapping +// functions can write into the top level of the map they are handed and two items must never share +// it. The nested values stay shared, which is why the mapping templates must treat their input +// payload as read only. +func resourceDataToEmit(resourceType string, values map[string]any, configured map[string]source.Extra, operation source.DataOperation, timestamp time.Time) []source.Data { + emitted := subTypesToEmit(resourceType, values, configured, operation == source.DataOperationDelete) + + data := make([]source.Data, 0, 1+len(emitted)) + data = append(data, source.Data{ + Type: resourceType, + Operation: operation, + Time: timestamp, + Values: values, + }) + + for _, typeKey := range emitted { + data = append(data, source.Data{ + Type: typeKey, + Operation: operation, + Time: timestamp, + Values: maps.Clone(values), + }) + } + + return data +} + +// warnOrphanSubTypes logs a warning for every configured sub-type mapping whose parent type mapping +// is not configured. A sub-type is emitted only while its parent resource is handled, so such a +// mapping can never produce any item. It is a warning, and not an error, because the source always +// skips the configuration it cannot use. +func warnOrphanSubTypes(log logger.Logger, typesToFilter map[string]source.Extra) { + configuredTypes := slices.Sorted(maps.Keys(typesToFilter)) + for _, parentType := range slices.Sorted(maps.Keys(subTypes)) { + if _, configured := configuredResourceType(configuredTypes, parentType); configured { + continue + } + + for _, candidate := range subTypes[parentType] { + if _, found := typesToFilter[candidate.typeKey]; !found { + continue + } + + log.Warn("sub-type mapping configured without the mapping of its parent type, it will never produce any item", + "type", candidate.typeKey, "parentType", parentType) + } + } +} diff --git a/internal/source/azure/subtypes_test.go b/internal/source/azure/subtypes_test.go new file mode 100644 index 0000000..cb4166d --- /dev/null +++ b/internal/source/azure/subtypes_test.go @@ -0,0 +1,395 @@ +// Copyright Mia srl +// SPDX-License-Identifier: AGPL-3.0-only or Commercial + +package azure + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mia-platform/ibdm/internal/logger" + "github.com/mia-platform/ibdm/internal/source" +) + +func TestIsFunctionApp(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + values map[string]any + expectedResult bool + }{ + "the single functionapp token matches": { + values: map[string]any{kindKey: "functionapp"}, + expectedResult: true, + }, + "the functionapp token in a list matches": { + values: map[string]any{kindKey: functionAppKindValue}, + expectedResult: true, + }, + "the functionapp token in a longer list matches": { + values: map[string]any{kindKey: "functionapp,linux,container"}, + expectedResult: true, + }, + "the token casing is ignored": { + values: map[string]any{kindKey: "FunctionApp,Linux"}, + expectedResult: true, + }, + "the token spacing is ignored": { + values: map[string]any{kindKey: "functionapp , linux"}, + expectedResult: true, + }, + "a web app does not match": { + values: map[string]any{kindKey: "app"}, + }, + "a linux web app does not match": { + values: map[string]any{kindKey: webAppKindValue}, + }, + "a token containing the term does not match": { + values: map[string]any{kindKey: "myfunctionapp"}, + }, + "a token ending with the term does not match": { + values: map[string]any{kindKey: "app,linux,functionapps"}, + }, + "an empty kind does not match": { + values: map[string]any{kindKey: ""}, + }, + "a missing kind does not match": { + values: map[string]any{idKey: normalizedWebsiteID}, + }, + "a non string kind does not match": { + values: map[string]any{kindKey: 42}, + }, + "a nil kind does not match": { + values: map[string]any{kindKey: nil}, + }, + "an empty payload does not match": { + values: map[string]any{}, + }, + } + + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expectedResult, isFunctionApp(test.values)) + }) + } +} + +func TestSubTypesFor(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + resourceType string + expectedTypeKeys []string + }{ + "the configured parent type returns its sub-types": { + resourceType: websitesType, + expectedTypeKeys: []string{functionAppsType}, + }, + "a lowercase parent type returns its sub-types": { + resourceType: "microsoft.web/sites", + expectedTypeKeys: []string{functionAppsType}, + }, + "a shouty parent type returns its sub-types": { + resourceType: "MICROSOFT.WEB/SITES", + expectedTypeKeys: []string{functionAppsType}, + }, + "a type without sub-types returns nothing": { + resourceType: managedClustersType, + }, + "a sub-type key is not a parent type": { + resourceType: functionAppsType, + }, + "an empty type returns nothing": {}, + } + + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + candidates := subTypesFor(test.resourceType) + typeKeys := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + typeKeys = append(typeKeys, candidate.typeKey) + assert.NotNil(t, candidate.matches, "every sub-type must carry the check guarding it") + } + + assert.ElementsMatch(t, test.expectedTypeKeys, typeKeys) + }) + } +} + +func TestIsSubTypeKey(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + typeKey string + expectedResult bool + }{ + "a declared sub-type key is one": { + typeKey: functionAppsType, + expectedResult: true, + }, + "an Azure provider type is not one": { + typeKey: websitesType, + }, + "an unmapped type is not one": { + typeKey: managedClustersType, + }, + "an empty type is not one": {}, + } + + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.expectedResult, isSubTypeKey(test.typeKey)) + }) + } +} + +func TestSubTypesToEmit(t *testing.T) { + t.Parallel() + + bothMappings := map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + } + parentMappingOnly := map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + } + + testCases := map[string]struct { + resourceType string + values map[string]any + configured map[string]source.Extra + isDelete bool + expectedKeys []string + }{ + "an upsert of a matching payload returns the sub-type": { + resourceType: websitesType, + values: map[string]any{idKey: normalizedWebsiteID, kindKey: functionAppKindValue}, + configured: bothMappings, + expectedKeys: []string{functionAppsType}, + }, + "an upsert of a non matching payload returns nothing": { + resourceType: websitesType, + values: map[string]any{idKey: normalizedWebsiteID, kindKey: webAppKindValue}, + configured: bothMappings, + }, + "an upsert of a matching payload of a differently cased type returns the sub-type": { + resourceType: "microsoft.web/sites", + values: map[string]any{idKey: normalizedWebsiteID, kindKey: functionAppKindValue}, + configured: bothMappings, + expectedKeys: []string{functionAppsType}, + }, + "a delete returns every configured sub-type whatever the payload": { + resourceType: websitesType, + values: map[string]any{idKey: normalizedWebsiteID}, + configured: bothMappings, + isDelete: true, + expectedKeys: []string{functionAppsType}, + }, + "an upsert returns nothing when the sub-type mapping is not loaded": { + resourceType: websitesType, + values: map[string]any{idKey: normalizedWebsiteID, kindKey: functionAppKindValue}, + configured: parentMappingOnly, + }, + "a delete returns nothing when the sub-type mapping is not loaded": { + resourceType: websitesType, + values: map[string]any{idKey: normalizedWebsiteID}, + configured: parentMappingOnly, + isDelete: true, + }, + "a type without sub-types returns nothing": { + resourceType: managedClustersType, + values: map[string]any{idKey: normalizedManagedClusterID, kindKey: "functionapp"}, + configured: bothMappings, + }, + "a type without sub-types returns nothing on a delete too": { + resourceType: managedClustersType, + values: map[string]any{idKey: normalizedManagedClusterID}, + configured: bothMappings, + isDelete: true, + }, + } + + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + assert.ElementsMatch(t, test.expectedKeys, subTypesToEmit(test.resourceType, test.values, test.configured, test.isDelete)) + }) + } +} + +func TestResourceDataToEmit(t *testing.T) { + t.Parallel() + + bothMappings := map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + } + functionAppValues := map[string]any{ + idKey: normalizedWebsiteID, + typeKey: websitesType, + kindKey: functionAppKindValue, + } + webAppValues := map[string]any{ + idKey: normalizedWebsiteID, + typeKey: websitesType, + kindKey: webAppKindValue, + } + deleteValues := map[string]any{ + idKey: normalizedWebsiteID, + typeKey: websitesType, + } + + testCases := map[string]struct { + resourceType string + values map[string]any + configured map[string]source.Extra + operation source.DataOperation + expectedData []source.Data + }{ + "a matching resource emits its item and the one of its sub-type": { + resourceType: websitesType, + values: functionAppValues, + configured: bothMappings, + operation: source.DataOperationUpsert, + expectedData: []source.Data{ + {Type: websitesType, Operation: source.DataOperationUpsert, Time: testTime, Values: functionAppValues}, + {Type: functionAppsType, Operation: source.DataOperationUpsert, Time: testTime, Values: functionAppValues}, + }, + }, + "a non matching resource emits its item alone": { + resourceType: websitesType, + values: webAppValues, + configured: bothMappings, + operation: source.DataOperationUpsert, + expectedData: []source.Data{ + {Type: websitesType, Operation: source.DataOperationUpsert, Time: testTime, Values: webAppValues}, + }, + }, + "a delete broadcasts to every configured sub-type": { + resourceType: websitesType, + values: deleteValues, + configured: bothMappings, + operation: source.DataOperationDelete, + expectedData: []source.Data{ + {Type: websitesType, Operation: source.DataOperationDelete, Time: testTime, Values: deleteValues}, + {Type: functionAppsType, Operation: source.DataOperationDelete, Time: testTime, Values: deleteValues}, + }, + }, + "a type without sub-types emits its item alone": { + resourceType: managedClustersType, + values: map[string]any{idKey: normalizedManagedClusterID, typeKey: managedClustersType}, + configured: map[string]source.Extra{managedClustersType: nil}, + operation: source.DataOperationUpsert, + expectedData: []source.Data{ + { + Type: managedClustersType, + Operation: source.DataOperationUpsert, + Time: testTime, + Values: map[string]any{idKey: normalizedManagedClusterID, typeKey: managedClustersType}, + }, + }, + }, + } + + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + data := resourceDataToEmit(test.resourceType, test.values, test.configured, test.operation, testTime) + + // the item of the resource is always emitted first, so that it exists before the + // relationship of a sub-type references it + require.Equal(t, test.expectedData, data) + }) + } +} + +// TestResourceDataToEmitIsolatesThePayloads checks the property the emission relies on: the +// mapping functions can write into the top level of the payload they are handed, so two items must +// never share one map. +func TestResourceDataToEmitIsolatesThePayloads(t *testing.T) { + t.Parallel() + + values := map[string]any{ + idKey: normalizedWebsiteID, + typeKey: websitesType, + kindKey: functionAppKindValue, + } + + data := resourceDataToEmit(websitesType, values, map[string]source.Extra{ + websitesType: {apiVersionKey: websitesAPIVersion}, + functionAppsType: {apiVersionKey: websitesAPIVersion}, + }, source.DataOperationUpsert, testTime) + require.Len(t, data, 2) + + data[0].Values["writtenByTheMapper"] = true + assert.NotContains(t, data[1].Values, "writtenByTheMapper") + + data[1].Values["writtenByTheOtherMapper"] = true + assert.NotContains(t, data[0].Values, "writtenByTheOtherMapper") +} + +func TestWarnOrphanSubTypes(t *testing.T) { + t.Parallel() + + const orphanMessage = "without the mapping of its parent type" + + testCases := map[string]struct { + typesToFilter map[string]source.Extra + expectedMessages []string + absentMessages []string + }{ + "a sub-type mapping loaded alone is reported": { + typesToFilter: map[string]source.Extra{functionAppsType: nil}, + expectedMessages: []string{orphanMessage, functionAppsType, websitesType}, + }, + "a sub-type mapping loaded with its parent stays silent": { + typesToFilter: map[string]source.Extra{websitesType: nil, functionAppsType: nil}, + absentMessages: []string{orphanMessage}, + }, + "a sub-type mapping loaded with a differently cased parent stays silent": { + typesToFilter: map[string]source.Extra{"microsoft.web/sites": nil, functionAppsType: nil}, + absentMessages: []string{orphanMessage}, + }, + "a parent mapping loaded alone stays silent": { + typesToFilter: map[string]source.Extra{websitesType: nil}, + absentMessages: []string{orphanMessage}, + }, + "an unrelated mapping stays silent": { + typesToFilter: map[string]source.Extra{managedClustersType: nil}, + absentMessages: []string{orphanMessage}, + }, + "no mapping stays silent": { + absentMessages: []string{orphanMessage}, + }, + } + + for testName, test := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + buffer := new(bytes.Buffer) + log := logger.NewLogger(buffer) + log.SetLevel(logger.TRACE) + + warnOrphanSubTypes(log, test.typesToFilter) + + for _, message := range test.expectedMessages { + assert.Contains(t, buffer.String(), message) + } + for _, message := range test.absentMessages { + assert.NotContains(t, buffer.String(), message) + } + }) + } +}