Azure: Variable editor and resource picker improvements (#101695)

* Update namespace endpoint to filter out only relevant namespaces

* Update tests

* Fix url builder tests

* Add todo comments

* Update func to use ARG to retrieve namespaces with metrics

* Refactor getMetricNamespaces for readability

* Lint

* Remove comments

* Remove type assertion

* Refactor ARG query

* Update tests and refactor class to use ARG

* Update resource group query

- Updates the resource groups query to support users/apps with restricted permissions

* Update resources request to be paginated

- Also order by name
- Add tests

* Start refactoring azure monitor util functions to resource graph

* Minor lint

* Add getMetricNamespaces resource graph function

* Modify getMetricNamespaces call

- Use resource graph function for variable queries

* Return names for getResourceNames values

* Use getMetricNamespaces variable specific req in editor

* Substantial refactor

- Update Azure Resource Graph data source with a method for making paged requests and a method for retrieving metric namespaces (and add tests)
- Extract helpers from azure_monitor_datasource to utils and generalise them (also revert previous changes to datasource and test file)
- Update mock with Azure Resource Graph data source
- Revert response parser changes
- Revert url builder changes
- Update get metric namespaces query to use the resource graph method for variable queries
- Update docs

* Lint

* Oops

* Fix type

* Lint and betterer

* Simplify imports

* Improve type

* Simplify resource picker class

* Start updating tests

* Fix naming and include missing error

* Update resource graph data source mock

* Update tests

* Remove unneeded parser

* Add get subscriptions to resource graph

* Generalise resource groups request

* Fix resource names request to ensure no clashing

* Update types

* Use resource graph queries for resource picker

* Correctly map resource group names

* Update mocks

* Update tests

* Fix mapping

* Refactor getResourceNames

- Remove most of the logic from resourcePickerData
- Add helper for parsing resource names as template vars
- Some renames for clarity
- Update types
- Update tests

* Ensure namespaces are lowercase

* Update docs/sources/datasources/azure-monitor/template-variables/index.md

Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>

* Prettier write

* Ensure we return all namespaces if resourceGroup isn't specified

---------

Co-authored-by: alyssabull <alyssabull@gmail.com>
Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>
This commit is contained in:
Andreas Christou
2025-03-06 19:08:35 +00:00
committed by GitHub
parent 32624b3251
commit c7b0bbd262
16 changed files with 313 additions and 424 deletions

View File

@ -10,26 +10,38 @@ export const createMockARGSubscriptionResponse = (): AzureGraphResponse<RawAzure
{ {
subscriptionId: '1', subscriptionId: '1',
subscriptionName: 'Primary Subscription', subscriptionName: 'Primary Subscription',
subscriptionURI: '/subscriptions/1',
count: 1,
}, },
{ {
subscriptionId: '2', subscriptionId: '2',
subscriptionName: 'Dev Subscription', subscriptionName: 'Dev Subscription',
subscriptionURI: '/subscriptions/2',
count: 1,
}, },
{ {
subscriptionId: '3', subscriptionId: '3',
subscriptionName: 'Dev Subscription', subscriptionName: 'Dev Subscription',
subscriptionURI: '/subscriptions/3',
count: 1,
}, },
{ {
subscriptionId: '4', subscriptionId: '4',
subscriptionName: 'Primary Subscription', subscriptionName: 'Primary Subscription',
subscriptionURI: '/subscriptions/4',
count: 1,
}, },
{ {
subscriptionId: '5', subscriptionId: '5',
subscriptionName: 'Primary Subscription', subscriptionName: 'Primary Subscription',
subscriptionURI: '/subscriptions/5',
count: 1,
}, },
{ {
subscriptionId: '6', subscriptionId: '6',
subscriptionName: 'Dev Subscription', subscriptionName: 'Dev Subscription',
subscriptionURI: '/subscriptions/6',
count: 1,
}, },
], ],
}); });
@ -39,31 +51,37 @@ export const createMockARGResourceGroupsResponse = (): AzureGraphResponse<RawAzu
{ {
resourceGroupURI: '/subscriptions/abc-123/resourceGroups/prod', resourceGroupURI: '/subscriptions/abc-123/resourceGroups/prod',
resourceGroupName: 'Production', resourceGroupName: 'Production',
count: 1,
}, },
{ {
resourceGroupURI: '/subscriptions/def-456/resourceGroups/dev', resourceGroupURI: '/subscriptions/def-456/resourceGroups/dev',
resourceGroupName: 'Development', resourceGroupName: 'Development',
count: 1,
}, },
{ {
resourceGroupURI: '/subscriptions/def-456/resourceGroups/test', resourceGroupURI: '/subscriptions/def-456/resourceGroups/test',
resourceGroupName: 'Test', resourceGroupName: 'Test',
count: 1,
}, },
{ {
resourceGroupURI: '/subscriptions/abc-123/resourceGroups/test', resourceGroupURI: '/subscriptions/abc-123/resourceGroups/test',
resourceGroupName: 'Test', resourceGroupName: 'Test',
count: 1,
}, },
{ {
resourceGroupURI: '/subscriptions/abc-123/resourceGroups/pre-prod', resourceGroupURI: '/subscriptions/abc-123/resourceGroups/pre-prod',
resourceGroupName: 'Pre-production', resourceGroupName: 'Pre-production',
count: 1,
}, },
{ {
resourceGroupURI: '/subscriptions/def-456/resourceGroups/qa', resourceGroupURI: '/subscriptions/def-456/resourceGroups/qa',
resourceGroupName: 'QA', resourceGroupName: 'QA',
count: 1,
}, },
], ],
}); });

View File

@ -75,7 +75,10 @@ export default function createMockDatasource(overrides?: DeepPartial<Datasource>
getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}), getResourceURIDisplayProperties: jest.fn().mockResolvedValue({}),
}, },
azureResourceGraphDatasource: {}, azureResourceGraphDatasource: {
pagedResourceGraphRequest: jest.fn().mockResolvedValue([]),
...overrides?.azureResourceGraphDatasource,
},
getVariablesRaw: jest.fn().mockReturnValue([]), getVariablesRaw: jest.fn().mockReturnValue([]),
currentUserAuth: false, currentUserAuth: false,
...overrides, ...overrides,

View File

@ -7,7 +7,13 @@ import createMockQuery from '../__mocks__/query';
import { createTemplateVariables } from '../__mocks__/utils'; import { createTemplateVariables } from '../__mocks__/utils';
import { multiVariable } from '../__mocks__/variables'; import { multiVariable } from '../__mocks__/variables';
import AzureMonitorDatasource from '../datasource'; import AzureMonitorDatasource from '../datasource';
import { AzureAPIResponse, AzureMonitorDataSourceInstanceSettings, Location } from '../types'; import {
AzureAPIResponse,
AzureMonitorDataSourceInstanceSettings,
Location,
RawAzureResourceGroupItem,
RawAzureResourceItem,
} from '../types';
// We want replace to just return the value as is in general/ // We want replace to just return the value as is in general/
// We declare this as a function so that we can overwrite it in each test // We declare this as a function so that we can overwrite it in each test
@ -754,20 +760,20 @@ describe('AzureMonitorDatasource', () => {
describe('When performing getResourceGroups', () => { describe('When performing getResourceGroups', () => {
const response = { const response = {
value: [{ name: 'grp1' }, { name: 'grp2' }], data: [{ resourceGroupName: 'grp1' }, { resourceGroupName: 'grp2' }],
}; };
beforeEach(() => { beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockResolvedValue(response); ctx.ds.azureResourceGraphDatasource.postResource = jest.fn().mockResolvedValue(response);
}); });
it('should return list of Resource Groups', () => { it('should return list of Resource Groups', () => {
return ctx.ds.getResourceGroups('subscriptionId').then((results: Array<{ text: string; value: string }>) => { return ctx.ds.getResourceGroups('subscriptionId').then((results: RawAzureResourceGroupItem[]) => {
expect(results.length).toEqual(2); expect(results.length).toEqual(2);
expect(results[0].text).toEqual('grp1'); expect(results[0].resourceGroupName).toEqual('grp1');
expect(results[0].value).toEqual('grp1'); expect(results[0].resourceGroupName).toEqual('grp1');
expect(results[1].text).toEqual('grp2'); expect(results[1].resourceGroupName).toEqual('grp2');
expect(results[1].value).toEqual('grp2'); expect(results[1].resourceGroupName).toEqual('grp2');
}); });
}); });
}); });
@ -786,11 +792,7 @@ describe('AzureMonitorDatasource', () => {
describe('and there are no special cases', () => { describe('and there are no special cases', () => {
const response = { const response = {
value: [ data: [
{
name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules',
},
{ {
name: resourceGroup, name: resourceGroup,
type: metricNamespace, type: metricNamespace,
@ -799,13 +801,7 @@ describe('AzureMonitorDatasource', () => {
}; };
beforeEach(() => { beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { ctx.ds.azureResourceGraphDatasource.postResource = jest.fn().mockImplementation((path: string) => {
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
expect(path).toBe(
`${basePath}/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${metricNamespace}'${
region ? ` and location eq '${region}'` : ''
}`
);
return Promise.resolve(response); return Promise.resolve(response);
}); });
}); });
@ -813,10 +809,10 @@ describe('AzureMonitorDatasource', () => {
it('should return list of Resource Names', () => { it('should return list of Resource Names', () => {
return ctx.ds return ctx.ds
.getResourceNames(subscription, resourceGroup, metricNamespace) .getResourceNames(subscription, resourceGroup, metricNamespace)
.then((results: Array<{ text: string; value: string }>) => { .then((results: RawAzureResourceItem[]) => {
expect(results.length).toEqual(1); expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
}); });
}); });
@ -824,10 +820,10 @@ describe('AzureMonitorDatasource', () => {
metricNamespace = 'microsoft.insights/Components'; metricNamespace = 'microsoft.insights/Components';
return ctx.ds return ctx.ds
.getResourceNames(subscription, resourceGroup, metricNamespace) .getResourceNames(subscription, resourceGroup, metricNamespace)
.then((results: Array<{ text: string; value: string }>) => { .then((results: RawAzureResourceItem[]) => {
expect(results.length).toEqual(1); expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
}); });
}); });
@ -835,10 +831,10 @@ describe('AzureMonitorDatasource', () => {
region = 'eastus'; region = 'eastus';
return ctx.ds return ctx.ds
.getResourceNames(subscription, resourceGroup, metricNamespace, region) .getResourceNames(subscription, resourceGroup, metricNamespace, region)
.then((results: Array<{ text: string; value: string }>) => { .then((results: RawAzureResourceItem[]) => {
expect(results.length).toEqual(1); expect(results.length).toEqual(1);
expect(results[0].text).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
}); });
}); });
@ -869,43 +865,34 @@ describe('AzureMonitorDatasource', () => {
return target === `$${multiVariable.id}` ? 'foo,bar' : (target ?? ''); return target === `$${multiVariable.id}` ? 'foo,bar' : (target ?? '');
}; };
const ds = new AzureMonitorDatasource(ctx.instanceSettings); const ds = new AzureMonitorDatasource(ctx.instanceSettings);
//ds.azureMonitorDatasource.templateSrv = tsrv; ds.azureResourceGraphDatasource.postResource = jest
ds.azureMonitorDatasource.getResource = jest
.fn() .fn()
.mockImplementationOnce((path: string) => { .mockImplementationOnce(() => Promise.resolve(response))
expect(path).toMatch('foo'); .mockImplementationOnce(() =>
return Promise.resolve(response); Promise.resolve({
}) data: [
.mockImplementationOnce((path: string) => {
expect(path).toMatch('bar');
return Promise.resolve({
value: [
{ {
name: resourceGroup + '2', name: resourceGroup + '2',
type: metricNamespace, type: metricNamespace,
}, },
], ],
}); })
}); );
return ds return ds
.getResourceNames(subscription, `$${multiVariable.id}`, metricNamespace) .getResourceNames(subscription, `$${multiVariable.id}`, metricNamespace)
.then((results: Array<{ text: string; value: string }>) => { .then((results: RawAzureResourceItem[]) => {
expect(results.length).toEqual(2); expect(results.length).toEqual(2);
expect(results[0].text).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
expect(results[0].value).toEqual('nodeapp'); expect(results[0].name).toEqual('nodeapp');
expect(results[1].text).toEqual('nodeapp2'); expect(results[1].name).toEqual('nodeapp2');
expect(results[1].value).toEqual('nodeapp2'); expect(results[1].name).toEqual('nodeapp2');
}); });
}); });
}); });
describe('and the metric definition is blobServices', () => { describe('and the metric definition is blobServices', () => {
const response = { const response = {
value: [ data: [
{
name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules',
},
{ {
name: 'storagetest', name: 'storagetest',
type: 'microsoft.storage/storageaccounts', type: 'microsoft.storage/storageaccounts',
@ -916,78 +903,32 @@ describe('AzureMonitorDatasource', () => {
it('should return list of Resource Names', () => { it('should return list of Resource Names', () => {
metricNamespace = 'microsoft.storage/storageaccounts/blobservices'; metricNamespace = 'microsoft.storage/storageaccounts/blobservices';
const validMetricNamespace = 'microsoft.storage/storageaccounts'; const validMetricNamespace = 'microsoft.storage/storageaccounts';
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { ctx.ds.azureResourceGraphDatasource.postResource = jest
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`; .fn()
expect(path).toBe( .mockImplementation(() => Promise.resolve(response));
basePath +
`/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${validMetricNamespace}'`
);
return Promise.resolve(response);
});
return ctx.ds return ctx.ds
.getResourceNames(subscription, resourceGroup, metricNamespace) .getResourceNames(subscription, resourceGroup, metricNamespace)
.then((results: Array<{ text: string; value: string }>) => { .then((results: RawAzureResourceItem[]) => {
expect(results.length).toEqual(1); expect(results.length).toEqual(1);
expect(results[0].text).toEqual('storagetest/default'); expect(results[0].name).toEqual('storagetest');
expect(results[0].value).toEqual('storagetest/default'); expect(results[0].name).toEqual('storagetest');
expect(ctx.ds.azureMonitorDatasource.getResource).toHaveBeenCalledWith( expect(ctx.ds.azureResourceGraphDatasource.postResource).toHaveBeenCalledWith(
`azuremonitor/subscriptions/${subscription}/resourceGroups/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${validMetricNamespace}'` 'resourcegraph/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01',
{
options: { resultFormat: 'objectArray' },
query: `resources
| where id hasprefix \"/subscriptions/${subscription}/resourceGroups/${resourceGroup}/\"
| where type == '${validMetricNamespace}'
| order by tolower(name) asc`,
}
); );
}); });
}); });
}); });
describe('and there are several pages', () => {
const skipToken = 'token';
const response1 = {
value: [
{
name: `${resourceGroup}1`,
type: metricNamespace,
},
],
nextLink: `https://management.azure.com/resourceuri?$skiptoken=${skipToken}`,
};
const response2 = {
value: [
{
name: `${resourceGroup}2`,
type: metricNamespace,
},
],
};
beforeEach(() => {
const fn = jest.fn();
ctx.ds.azureMonitorDatasource.getResource = fn;
const basePath = `azuremonitor/subscriptions/${subscription}/resourceGroups`;
const expectedPath = `${basePath}/${resourceGroup}/resources?api-version=2021-04-01&$filter=resourceType eq '${metricNamespace}'`;
// first page
fn.mockImplementationOnce((path: string) => {
expect(path).toBe(expectedPath);
return Promise.resolve(response1);
});
// second page
fn.mockImplementationOnce((path: string) => {
expect(path).toBe(`${expectedPath}&$skiptoken=${skipToken}`);
return Promise.resolve(response2);
});
});
it('should return list of Resource Names', () => {
return ctx.ds
.getResourceNames(subscription, resourceGroup, metricNamespace)
.then((results: Array<{ text: string; value: string }>) => {
expect(results.length).toEqual(2);
expect(results[0].value).toEqual(`${resourceGroup}1`);
expect(results[1].value).toEqual(`${resourceGroup}2`);
});
});
});
describe('without a resource group or a metric definition', () => { describe('without a resource group or a metric definition', () => {
const response = { const response = {
value: [ data: [
{ {
name: 'Failure Anomalies - nodeapp', name: 'Failure Anomalies - nodeapp',
type: 'microsoft.insights/alertrules', type: 'microsoft.insights/alertrules',
@ -1000,15 +941,13 @@ describe('AzureMonitorDatasource', () => {
}; };
beforeEach(() => { beforeEach(() => {
ctx.ds.azureMonitorDatasource.getResource = jest.fn().mockImplementation((path: string) => { ctx.ds.azureResourceGraphDatasource.postResource = jest.fn().mockImplementation((path: string) => {
const basePath = `azuremonitor/subscriptions/${subscription}/resources?api-version=2021-04-01`;
expect(path).toBe(basePath);
return Promise.resolve(response); return Promise.resolve(response);
}); });
}); });
it('should return list of Resource Names', () => { it('should return list of Resource Names', () => {
return ctx.ds.getResourceNames(subscription).then((results: Array<{ text: string; value: string }>) => { return ctx.ds.getResourceNames(subscription).then((results: RawAzureResourceItem[]) => {
expect(results.length).toEqual(2); expect(results.length).toEqual(2);
}); });
}); });

View File

@ -1,4 +1,4 @@
import { find, startsWith } from 'lodash'; import { find } from 'lodash';
import { AzureCredentials } from '@grafana/azure-sdk'; import { AzureCredentials } from '@grafana/azure-sdk';
import { ScopedVars } from '@grafana/data'; import { ScopedVars } from '@grafana/data';
@ -20,10 +20,8 @@ import {
AzureMonitorLocations, AzureMonitorLocations,
AzureMonitorProvidersResponse, AzureMonitorProvidersResponse,
AzureAPIResponse, AzureAPIResponse,
AzureGetResourceNamesQuery,
Subscription, Subscription,
Location, Location,
ResourceGroup,
Metric, Metric,
MetricNamespace, MetricNamespace,
} from '../types'; } from '../types';
@ -178,68 +176,6 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend<
}); });
} }
getResourceGroups(subscriptionId: string) {
return this.getResource(
`${this.resourcePath}/subscriptions/${subscriptionId}/resourceGroups?api-version=${this.listByResourceGroupApiVersion}`
).then((result: AzureAPIResponse<ResourceGroup>) => {
return ResponseParser.parseResponseValues<ResourceGroup>(result, 'name', 'name');
});
}
async getResourceNames(query: AzureGetResourceNamesQuery, skipToken?: string) {
const promises = replaceTemplateVariables(this.templateSrv, query).map(
({ metricNamespace, subscriptionId, resourceGroup, region }) => {
const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')
? 'microsoft.storage/storageaccounts'
: metricNamespace;
let url = `${this.resourcePath}/subscriptions/${subscriptionId}`;
if (resourceGroup) {
url += `/resourceGroups/${resourceGroup}`;
}
url += `/resources?api-version=${this.listByResourceGroupApiVersion}`;
const filters: string[] = [];
if (validMetricNamespace) {
filters.push(`resourceType eq '${validMetricNamespace}'`);
}
if (region) {
filters.push(`location eq '${region}'`);
}
if (filters.length > 0) {
url += `&$filter=${filters.join(' and ')}`;
}
if (skipToken) {
url += `&$skiptoken=${skipToken}`;
}
return this.getResource(url).then(async (result) => {
let list: Array<{ text: string; value: string }> = [];
if (startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')) {
list = ResponseParser.parseResourceNames(result, 'microsoft.storage/storageaccounts');
for (let i = 0; i < list.length; i++) {
list[i].text += '/default';
list[i].value += '/default';
}
} else {
list = ResponseParser.parseResourceNames(result, metricNamespace);
}
if (result.nextLink) {
// If there is a nextLink, we should request more pages
const nextURL = new URL(result.nextLink);
const nextToken = nextURL.searchParams.get('$skiptoken');
if (!nextToken) {
throw Error('unable to request the next page of resources');
}
const nextPage = await this.getResourceNames({ metricNamespace, subscriptionId, resourceGroup }, nextToken);
list = list.concat(nextPage);
}
return list;
});
}
);
return (await Promise.all(promises)).flat();
}
// Note globalRegion should be false when querying custom metric namespaces // Note globalRegion should be false when querying custom metric namespaces
getMetricNamespaces(query: GetMetricNamespacesQuery, globalRegion: boolean, region?: string, custom?: boolean) { getMetricNamespaces(query: GetMetricNamespacesQuery, globalRegion: boolean, region?: string, custom?: boolean) {
const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl( const url = UrlBuilder.buildAzureMonitorGetMetricNamespacesUrl(

View File

@ -12,7 +12,6 @@ import {
AzureAPIResponse, AzureAPIResponse,
Location, Location,
Subscription, Subscription,
Resource,
} from '../types'; } from '../types';
export default class ResponseParser { export default class ResponseParser {
static parseResponseValues<T>( static parseResponseValues<T>(
@ -40,31 +39,6 @@ export default class ResponseParser {
return list; return list;
} }
static parseResourceNames(
result: AzureAPIResponse<Resource>,
metricNamespace?: string
): Array<{ text: string; value: string }> {
const list: Array<{ text: string; value: string }> = [];
if (!result) {
return list;
}
for (let i = 0; i < result.value.length; i++) {
if (
typeof result.value[i].type === 'string' &&
(!metricNamespace || result.value[i].type.toLocaleLowerCase() === metricNamespace.toLocaleLowerCase())
) {
list.push({
text: result.value[i].name,
value: result.value[i].name,
});
}
}
return list;
}
static parseMetadata(result: AzureMonitorMetricsMetadataResponse, metricName: string) { static parseMetadata(result: AzureMonitorMetricsMetadataResponse, metricName: string) {
const defaultAggTypes = ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count']; const defaultAggTypes = ['None', 'Average', 'Minimum', 'Maximum', 'Total', 'Count'];
const metricData = result?.value.find((v) => v.name.value === metricName); const metricData = result?.value.find((v) => v.name.value === metricName);

View File

@ -1,5 +1,4 @@
// eslint-disable-next-line lodash/import-scope import { startsWith, includes, find, filter } from 'lodash';
import _ from 'lodash';
import { ScopedVars } from '@grafana/data'; import { ScopedVars } from '@grafana/data';
import { getTemplateSrv, DataSourceWithBackend, TemplateSrv } from '@grafana/runtime'; import { getTemplateSrv, DataSourceWithBackend, TemplateSrv } from '@grafana/runtime';
@ -9,10 +8,13 @@ import {
AzureMonitorQuery, AzureMonitorQuery,
AzureMonitorDataSourceJsonData, AzureMonitorDataSourceJsonData,
AzureQueryType, AzureQueryType,
RawAzureResourceGroupItem,
AzureGetResourceNamesQuery,
AzureMonitorDataSourceInstanceSettings, AzureMonitorDataSourceInstanceSettings,
RawAzureResourceItem, RawAzureResourceItem,
AzureGraphResponse, AzureGraphResponse,
AzureResourceGraphOptions, AzureResourceGraphOptions,
RawAzureSubscriptionItem,
} from '../types'; } from '../types';
import { interpolateVariable, replaceTemplateVariables, routeNames } from '../utils/common'; import { interpolateVariable, replaceTemplateVariables, routeNames } from '../utils/common';
@ -41,14 +43,14 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend<
return target; return target;
} }
const variableNames = ts.getVariables().map((v) => `$${v.name}`); const variableNames = ts.getVariables().map((v) => `$${v.name}`);
const subscriptionVar = _.find(target.subscriptions, (sub) => _.includes(variableNames, sub)); const subscriptionVar = find(target.subscriptions, (sub) => includes(variableNames, sub));
const interpolatedSubscriptions = ts const interpolatedSubscriptions = ts
.replace(subscriptionVar, scopedVars, (v: string[] | string) => v) .replace(subscriptionVar, scopedVars, (v: string[] | string) => v)
.split(',') .split(',')
.filter((v) => v.length > 0); .filter((v) => v.length > 0);
const subscriptions = [ const subscriptions = [
...interpolatedSubscriptions, ...interpolatedSubscriptions,
..._.filter(target.subscriptions, (sub) => !_.includes(variableNames, sub)), ...filter(target.subscriptions, (sub) => !includes(variableNames, sub)),
]; ];
const query = ts.replace(item.query, scopedVars, interpolateVariable); const query = ts.replace(item.query, scopedVars, interpolateVariable);
@ -63,7 +65,7 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend<
}; };
} }
async pagedResourceGraphRequest<T = unknown>(query: string, maxRetries = 1): Promise<T[]> { async pagedResourceGraphRequest<T>(query: string, maxRetries = 1): Promise<T[]> {
try { try {
let allFetched = false; let allFetched = false;
let $skipToken = undefined; let $skipToken = undefined;
@ -101,6 +103,90 @@ export default class AzureResourceGraphDatasource extends DataSourceWithBackend<
} }
} }
async getSubscriptions() {
const query = `
resources
| join kind=inner (
ResourceContainers
| where type == 'microsoft.resources/subscriptions'
| project subscriptionName=name, subscriptionURI=id, subscriptionId
) on subscriptionId
| summarize count=count() by subscriptionName, subscriptionURI, subscriptionId
| order by subscriptionName desc
`;
const subscriptions = await this.pagedResourceGraphRequest<RawAzureSubscriptionItem>(query, 1);
return subscriptions;
}
async getResourceGroups(subscriptionId: string, metricNamespacesFilter?: string) {
// We can use subscription ID for the filtering here as they're unique
// The logic of this query is:
// Retrieve _all_ resources a user/app registration/identity has access to
// Filter by the namespaces that support metrics (if this request is from the resource picker)
// Filter to resources contained within the subscription
// Conduct a left-outer join on the resourcecontainers table to allow us to get the case-sensitive resource group name
// Return the count of resources in a group, the URI, and name of the group in ascending order
const query = `resources
${metricNamespacesFilter || ''}
| where subscriptionId == '${subscriptionId}'
| extend resourceGroupURI = strcat("/subscriptions/", subscriptionId, "/resourcegroups/", resourceGroup)
| join kind=leftouter (resourcecontainers
| where type =~ 'microsoft.resources/subscriptions/resourcegroups'
| project resourceGroupName=name, resourceGroupURI=tolower(id)) on resourceGroupURI
| project resourceGroupName=iff(resourceGroupName != "", resourceGroupName, resourceGroup), resourceGroupURI
| summarize count=count() by resourceGroupName, resourceGroupURI
| order by tolower(resourceGroupName) asc `;
const resourceGroups = await this.pagedResourceGraphRequest<RawAzureResourceGroupItem>(query);
return resourceGroups;
}
async getResourceNames(query: AzureGetResourceNamesQuery, metricNamespacesFilter?: string) {
const promises = replaceTemplateVariables(this.templateSrv, query).map(
async ({ metricNamespace, subscriptionId, resourceGroup, region, uri }) => {
const validMetricNamespace = startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')
? 'microsoft.storage/storageaccounts'
: metricNamespace;
// URI takes precedence over subscription ID and resource group
let prefix = uri;
if (!prefix) {
if (subscriptionId) {
prefix = `/subscriptions/${subscriptionId}`;
}
if (resourceGroup) {
prefix += `/resourceGroups/${resourceGroup}`;
}
}
const filters: string[] = [];
if (validMetricNamespace) {
// Ensure the namespace is always lowercase as that's how it's stored in Resource Graph
filters.push(`type == '${validMetricNamespace.toLowerCase()}'`);
}
if (region) {
filters.push(`location == '${region}'`);
}
// We use URIs for the filtering here because resource group names are not unique across subscriptions
// We also add a slash at the end of the URI to ensure we do not pull resources from a resource group
// that has a similar naming prefix e.g. resourceGroup1 and resourceGroup10
const query = `resources${metricNamespacesFilter ? '\n' + metricNamespacesFilter : ''}
| where id hasprefix "${prefix}/"
${filters.length > 0 ? `| where ${filters.join(' and ')}` : ''}
| order by tolower(name) asc`;
const resources = await this.pagedResourceGraphRequest<RawAzureResourceItem>(query);
return resources;
}
);
return (await Promise.all(promises)).flat();
}
// Retrieve metric namespaces relevant to a subscription/resource group/resource // Retrieve metric namespaces relevant to a subscription/resource group/resource
async getMetricNamespaces(resourceUri: string) { async getMetricNamespaces(resourceUri: string) {
const promises = replaceTemplateVariables(this.templateSrv, { resourceUri }).map(async ({ resourceUri }) => { const promises = replaceTemplateVariables(this.templateSrv, { resourceUri }).map(async ({ resourceUri }) => {

View File

@ -34,7 +34,8 @@ export function createMockResourcePickerData() {
const mockDatasource = createMockDatasource(); const mockDatasource = createMockDatasource();
const mockResourcePicker = new ResourcePickerData( const mockResourcePicker = new ResourcePickerData(
createMockInstanceSetttings(), createMockInstanceSetttings(),
mockDatasource.azureMonitorDatasource mockDatasource.azureMonitorDatasource,
mockDatasource.azureResourceGraphDatasource
); );
mockResourcePicker.getSubscriptions = jest.fn().mockResolvedValue(createMockSubscriptions()); mockResourcePicker.getSubscriptions = jest.fn().mockResolvedValue(createMockSubscriptions());

View File

@ -10,6 +10,8 @@ import {
mockResourcesByResourceGroup, mockResourcesByResourceGroup,
mockSearchResults, mockSearchResults,
} from '../../__mocks__/resourcePickerRows'; } from '../../__mocks__/resourcePickerRows';
import { DeepPartial } from '../../__mocks__/utils';
import Datasource from '../../datasource';
import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData'; import ResourcePickerData, { ResourcePickerQueryType } from '../../resourcePicker/resourcePickerData';
import { ResourceRowType } from './types'; import { ResourceRowType } from './types';
@ -32,11 +34,15 @@ const singleResourceSelectionURI =
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server'; '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server';
const noop = () => {}; const noop = () => {};
function createMockResourcePickerData(preserveImplementation?: string[]) { function createMockResourcePickerData(
const mockDatasource = createMockDatasource(); preserveImplementation?: string[],
datasourceOverrides?: DeepPartial<Datasource>
) {
const mockDatasource = createMockDatasource(datasourceOverrides);
const mockResourcePicker = new ResourcePickerData( const mockResourcePicker = new ResourcePickerData(
createMockInstanceSetttings(), createMockInstanceSetttings(),
mockDatasource.azureMonitorDatasource mockDatasource.azureMonitorDatasource,
mockDatasource.azureResourceGraphDatasource
); );
const mockFunctions = omit( const mockFunctions = omit(
@ -332,7 +338,9 @@ describe('AzureMonitor ResourcePicker', () => {
}); });
it('should not throw an error if no namespaces are found - fallback used', async () => { it('should not throw an error if no namespaces are found - fallback used', async () => {
const resourcePickerData = createMockResourcePickerData(['getResourceGroupsBySubscriptionId']); const resourcePickerData = createMockResourcePickerData(['getResourceGroupsBySubscriptionId'], {
azureResourceGraphDatasource: { getResourceGroups: jest.fn().mockResolvedValue([]) },
});
resourcePickerData.postResource = jest.fn().mockResolvedValueOnce({ data: [] }); resourcePickerData.postResource = jest.fn().mockResolvedValueOnce({ data: [] });
render( render(
<ResourcePicker <ResourcePicker

View File

@ -26,7 +26,17 @@ jest.mock('@grafana/runtime', () => ({
}, },
}), }),
})); }));
const getResourceGroups = jest.fn().mockResolvedValue([{ resourceGroupURI: 'rg', resourceGroupName: 'rg', count: 1 }]);
const getResourceNames = jest.fn().mockResolvedValue([
{
id: 'foobarID',
name: 'foobar',
subscriptionId: 'subID',
resourceGroup: 'resourceGroup',
type: 'foobarType',
location: 'london',
},
]);
const defaultProps = { const defaultProps = {
query: { query: {
refId: 'A', refId: 'A',
@ -39,7 +49,6 @@ const defaultProps = {
onChange: jest.fn(), onChange: jest.fn(),
datasource: createMockDatasource({ datasource: createMockDatasource({
getSubscriptions: jest.fn().mockResolvedValue([{ text: 'Primary Subscription', value: 'sub' }]), getSubscriptions: jest.fn().mockResolvedValue([{ text: 'Primary Subscription', value: 'sub' }]),
getResourceGroups: jest.fn().mockResolvedValue([{ text: 'rg', value: 'rg' }]),
getMetricNamespaces: jest getMetricNamespaces: jest
.fn() .fn()
.mockImplementation( .mockImplementation(
@ -50,11 +59,16 @@ const defaultProps = {
return [{ text: 'foo/custom', value: 'foo/custom' }]; return [{ text: 'foo/custom', value: 'foo/custom' }];
} }
), ),
getResourceNames: jest.fn().mockResolvedValue([{ text: 'foobar', value: 'foobar' }]),
getVariablesRaw: jest.fn().mockReturnValue([ getVariablesRaw: jest.fn().mockReturnValue([
{ label: 'query0', name: 'sub0' }, { label: 'query0', name: 'sub0' },
{ label: 'query1', name: 'rg', query: { queryType: AzureQueryType.ResourceGroupsQuery } }, { label: 'query1', name: 'rg', query: { queryType: AzureQueryType.ResourceGroupsQuery } },
]), ]),
azureResourceGraphDatasource: {
getResourceGroups,
getResourceNames,
},
getResourceGroups,
getResourceNames,
}), }),
}; };

View File

@ -149,7 +149,7 @@ const VariableEditor = (props: Props) => {
useEffect(() => { useEffect(() => {
if (subscription) { if (subscription) {
datasource.getResourceGroups(subscription).then((rgs) => { datasource.getResourceGroups(subscription).then((rgs) => {
setResourceGroups(rgs.map((s) => ({ label: s.text, value: s.value }))); setResourceGroups(rgs.map((s) => ({ label: s.resourceGroupName, value: s.resourceGroupName })));
}); });
} }
}, [datasource, subscription]); }, [datasource, subscription]);
@ -179,8 +179,8 @@ const VariableEditor = (props: Props) => {
// When subscription, resource group, and namespace are all set, retrieve resource names // When subscription, resource group, and namespace are all set, retrieve resource names
useEffect(() => { useEffect(() => {
if (subscription && resourceGroup && namespace) { if (subscription && resourceGroup && namespace) {
datasource.getResourceNames(subscription, resourceGroup, namespace).then((rgs) => { datasource.getResourceNames(subscription, resourceGroup, namespace).then((resources) => {
setResources(rgs.map((s) => ({ label: s.text, value: s.value }))); setResources(resources.map((s) => ({ label: s.name, value: s.name })));
}); });
} }
}, [datasource, subscription, resourceGroup, namespace]); }, [datasource, subscription, resourceGroup, namespace]);

View File

@ -49,7 +49,11 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
this.azureMonitorDatasource = new AzureMonitorDatasource(instanceSettings); this.azureMonitorDatasource = new AzureMonitorDatasource(instanceSettings);
this.azureResourceGraphDatasource = new AzureResourceGraphDatasource(instanceSettings); this.azureResourceGraphDatasource = new AzureResourceGraphDatasource(instanceSettings);
this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(instanceSettings); this.azureLogAnalyticsDatasource = new AzureLogAnalyticsDatasource(instanceSettings);
this.resourcePickerData = new ResourcePickerData(instanceSettings, this.azureMonitorDatasource); this.resourcePickerData = new ResourcePickerData(
instanceSettings,
this.azureMonitorDatasource,
this.azureResourceGraphDatasource
);
this.pseudoDatasource = { this.pseudoDatasource = {
[AzureQueryType.AzureMonitor]: this.azureMonitorDatasource, [AzureQueryType.AzureMonitor]: this.azureMonitorDatasource,
@ -164,10 +168,6 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
} }
/* Azure Monitor REST API methods */ /* Azure Monitor REST API methods */
getResourceGroups(subscriptionId: string) {
return this.azureMonitorDatasource.getResourceGroups(this.templateSrv.replace(subscriptionId));
}
getMetricNamespaces( getMetricNamespaces(
subscriptionId: string, subscriptionId: string,
resourceGroup?: string, resourceGroup?: string,
@ -200,10 +200,6 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
); );
} }
getResourceNames(subscriptionId: string, resourceGroup?: string, metricNamespace?: string, region?: string) {
return this.azureMonitorDatasource.getResourceNames({ subscriptionId, resourceGroup, metricNamespace, region });
}
getMetricNames( getMetricNames(
subscriptionId: string, subscriptionId: string,
resourceGroup: string, resourceGroup: string,
@ -220,13 +216,27 @@ export default class Datasource extends DataSourceWithBackend<AzureMonitorQuery,
}); });
} }
getSubscriptions() {
return this.azureMonitorDatasource.getSubscriptions();
}
/*Azure Log Analytics */ /*Azure Log Analytics */
getAzureLogAnalyticsWorkspaces(subscriptionId: string) { getAzureLogAnalyticsWorkspaces(subscriptionId: string) {
return this.azureLogAnalyticsDatasource.getWorkspaces(subscriptionId); return this.azureLogAnalyticsDatasource.getWorkspaces(subscriptionId);
} }
getSubscriptions() { /*Azure Resource Graph */
return this.azureMonitorDatasource.getSubscriptions(); getResourceGroups(subscriptionId: string) {
return this.azureResourceGraphDatasource.getResourceGroups(this.templateSrv.replace(subscriptionId));
}
getResourceNames(subscriptionId: string, resourceGroup?: string, metricNamespace?: string, region?: string) {
return this.azureResourceGraphDatasource.getResourceNames({
subscriptionId,
resourceGroup,
metricNamespace,
region,
});
} }
interpolateVariablesInQueries(queries: AzureMonitorQuery[], scopedVars: ScopedVars): AzureMonitorQuery[] { interpolateVariablesInQueries(queries: AzureMonitorQuery[], scopedVars: ScopedVars): AzureMonitorQuery[] {

View File

@ -6,6 +6,7 @@ import {
import createMockDatasource from '../__mocks__/datasource'; import createMockDatasource from '../__mocks__/datasource';
import { createMockInstanceSetttings } from '../__mocks__/instanceSettings'; import { createMockInstanceSetttings } from '../__mocks__/instanceSettings';
import { resourceTypes } from '../azureMetadata'; import { resourceTypes } from '../azureMetadata';
import AzureResourceGraphDatasource from '../azure_resource_graph/azure_resource_graph_datasource';
import { ResourceRowType } from '../components/ResourcePicker/types'; import { ResourceRowType } from '../components/ResourcePicker/types';
import { AzureGraphResponse } from '../types'; import { AzureGraphResponse } from '../types';
@ -22,18 +23,24 @@ jest.mock('@grafana/runtime', () => ({
const createResourcePickerData = (responses: AzureGraphResponse[], noNamespaces?: boolean) => { const createResourcePickerData = (responses: AzureGraphResponse[], noNamespaces?: boolean) => {
const instanceSettings = createMockInstanceSetttings(); const instanceSettings = createMockInstanceSetttings();
const mockDatasource = createMockDatasource(); const azureResourceGraphDatasource = new AzureResourceGraphDatasource(instanceSettings);
const postResource = jest.fn();
responses.forEach((res) => {
postResource.mockResolvedValueOnce(res);
});
azureResourceGraphDatasource.postResource = postResource;
const mockDatasource = createMockDatasource({ azureResourceGraphDatasource: azureResourceGraphDatasource });
mockDatasource.azureMonitorDatasource.getMetricNamespaces = jest mockDatasource.azureMonitorDatasource.getMetricNamespaces = jest
.fn() .fn()
.mockResolvedValueOnce( .mockResolvedValueOnce(
noNamespaces ? [] : [{ text: 'Microsoft.Storage/storageAccounts', value: 'Microsoft.Storage/storageAccounts' }] noNamespaces ? [] : [{ text: 'Microsoft.Storage/storageAccounts', value: 'Microsoft.Storage/storageAccounts' }]
); );
const resourcePickerData = new ResourcePickerData(instanceSettings, mockDatasource.azureMonitorDatasource);
const postResource = jest.fn(); const resourcePickerData = new ResourcePickerData(
responses.forEach((res) => { instanceSettings,
postResource.mockResolvedValueOnce(res); mockDatasource.azureMonitorDatasource,
}); mockDatasource.azureResourceGraphDatasource
resourcePickerData.postResource = postResource; );
return { resourcePickerData, postResource, mockDatasource }; return { resourcePickerData, postResource, mockDatasource };
}; };
@ -301,32 +308,6 @@ describe('AzureMonitor resourcePickerData', () => {
}); });
}); });
it('throws an error if it recieves data with a malformed uri', async () => {
const mockResponse = {
data: [
{
id: '/a-differently-formatted/uri/than/the/type/we/planned/to/parse',
name: 'web-server',
type: 'Microsoft.Compute/virtualMachines',
resourceGroup: 'dev',
subscriptionId: 'def-456',
location: 'northeurope',
},
],
};
const { resourcePickerData } = createResourcePickerData([mockResponse]);
try {
await resourcePickerData.getResourcesForResourceGroup('dev', 'logs');
throw Error('expected getResourcesForResourceGroup to fail but it succeeded');
} catch (err) {
if (err instanceof Error) {
expect(err.message).toEqual('unable to fetch resource details');
} else {
throw err;
}
}
});
it('should filter metrics resources', async () => { it('should filter metrics resources', async () => {
const mockSubscriptionsResponse = createMockARGSubscriptionResponse(); const mockSubscriptionsResponse = createMockARGSubscriptionResponse();
const mockResourcesResponse = createARGResourcesResponse(); const mockResourcesResponse = createARGResourcesResponse();

View File

@ -2,6 +2,7 @@ import { DataSourceWithBackend, reportInteraction } from '@grafana/runtime';
import { logsResourceTypes, resourceTypeDisplayNames, resourceTypes } from '../azureMetadata'; import { logsResourceTypes, resourceTypeDisplayNames, resourceTypes } from '../azureMetadata';
import AzureMonitorDatasource from '../azure_monitor/azure_monitor_datasource'; import AzureMonitorDatasource from '../azure_monitor/azure_monitor_datasource';
import AzureResourceGraphDatasource from '../azure_resource_graph/azure_resource_graph_datasource';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types'; import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../components/ResourcePicker/types';
import { import {
addResources, addResources,
@ -14,18 +15,11 @@ import {
import { import {
AzureMonitorDataSourceInstanceSettings, AzureMonitorDataSourceInstanceSettings,
AzureMonitorDataSourceJsonData, AzureMonitorDataSourceJsonData,
AzureGraphResponse,
AzureMonitorResource, AzureMonitorResource,
AzureMonitorQuery, AzureMonitorQuery,
AzureResourceGraphOptions,
AzureResourceSummaryItem, AzureResourceSummaryItem,
RawAzureResourceGroupItem,
RawAzureResourceItem, RawAzureResourceItem,
RawAzureSubscriptionItem,
} from '../types'; } from '../types';
import { routeNames } from '../utils/common';
const RESOURCE_GRAPH_URL = '/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01';
const logsSupportedResourceTypesKusto = logsResourceTypes.map((v) => `"${v}"`).join(','); const logsSupportedResourceTypesKusto = logsResourceTypes.map((v) => `"${v}"`).join(',');
@ -35,18 +29,19 @@ export default class ResourcePickerData extends DataSourceWithBackend<
AzureMonitorQuery, AzureMonitorQuery,
AzureMonitorDataSourceJsonData AzureMonitorDataSourceJsonData
> { > {
private resourcePath: string;
resultLimit = 200; resultLimit = 200;
azureMonitorDatasource; azureMonitorDatasource;
azureResourceGraphDatasource;
supportedMetricNamespaces = ''; supportedMetricNamespaces = '';
constructor( constructor(
instanceSettings: AzureMonitorDataSourceInstanceSettings, instanceSettings: AzureMonitorDataSourceInstanceSettings,
azureMonitorDatasource: AzureMonitorDatasource azureMonitorDatasource: AzureMonitorDatasource,
azureResourceGraphDatasource: AzureResourceGraphDatasource
) { ) {
super(instanceSettings); super(instanceSettings);
this.resourcePath = `${routeNames.resourceGraph}`;
this.azureMonitorDatasource = azureMonitorDatasource; this.azureMonitorDatasource = azureMonitorDatasource;
this.azureResourceGraphDatasource = azureResourceGraphDatasource;
} }
async fetchInitialRows( async fetchInitialRows(
@ -111,7 +106,8 @@ export default class ResourcePickerData extends DataSourceWithBackend<
| order by tolower(name) asc | order by tolower(name) asc
| limit ${this.resultLimit} | limit ${this.resultLimit}
`; `;
const { data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(searchQuery); const response =
await this.azureResourceGraphDatasource.pagedResourceGraphRequest<RawAzureResourceItem>(searchQuery);
return response.map((item) => { return response.map((item) => {
const parsedUri = parseResourceURI(item.id); const parsedUri = parseResourceURI(item.id);
if (!parsedUri || !(parsedUri.resourceName || parsedUri.resourceGroup || parsedUri.subscription)) { if (!parsedUri || !(parsedUri.resourceName || parsedUri.resourceGroup || parsedUri.subscription)) {
@ -138,41 +134,14 @@ export default class ResourcePickerData extends DataSourceWithBackend<
}); });
}; };
// private
async getSubscriptions(): Promise<ResourceRowGroup> { async getSubscriptions(): Promise<ResourceRowGroup> {
const query = ` const subscriptions = await this.azureResourceGraphDatasource.getSubscriptions();
resources
| join kind=inner (
ResourceContainers
| where type == 'microsoft.resources/subscriptions'
| project subscriptionName=name, subscriptionURI=id, subscriptionId
) on subscriptionId
| summarize count() by subscriptionName, subscriptionURI, subscriptionId
| order by subscriptionName desc
`;
let resources: RawAzureSubscriptionItem[] = []; if (!subscriptions.length) {
throw new Error('No subscriptions were found');
let allFetched = false;
let $skipToken = undefined;
while (!allFetched) {
// The response may include several pages
let options: Partial<AzureResourceGraphOptions> = {};
if ($skipToken) {
options = {
$skipToken,
};
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureSubscriptionItem[]>(query, 1, options);
if (!resourceResponse.data.length) {
throw new Error('No subscriptions were found');
}
resources = resources.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
allFetched = !$skipToken;
} }
return resources.map((subscription) => ({ return subscriptions.map((subscription) => ({
name: subscription.subscriptionName, name: subscription.subscriptionName,
id: subscription.subscriptionId, id: subscription.subscriptionId,
uri: `/subscriptions/${subscription.subscriptionId}`, uri: `/subscriptions/${subscription.subscriptionId}`,
@ -186,41 +155,9 @@ export default class ResourcePickerData extends DataSourceWithBackend<
subscriptionId: string, subscriptionId: string,
type: ResourcePickerQueryType type: ResourcePickerQueryType
): Promise<ResourceRowGroup> { ): Promise<ResourceRowGroup> {
// We can use subscription ID for the filtering here as they're unique const filter = await this.filterByType(type);
// The logic of this query is:
// Retrieve _all_ resources a user/app registration/identity has access to
// Filter by the namespaces that support metrics
// Filter to resources contained within the subscription
// Conduct a left-outer join on the resourcecontainers table to allow us to get the case-sensitive resource group name
// Return the count of resources in a group, the URI, and name of the group in ascending order
const query = `
resources
${await this.filterByType(type)}
| where subscriptionId == '${subscriptionId}'
| extend resourceGroupURI = strcat("/subscriptions/", subscriptionId, "/resourcegroups/", resourceGroup)
| join kind=leftouter (resourcecontainers
| where type =~ 'microsoft.resources/subscriptions/resourcegroups'
| project resourceGroupName=name, resourceGroupURI=tolower(id)) on resourceGroupURI
| project resourceGroupName=iff(resourceGroupName != "", resourceGroupName, resourceGroup), resourceGroupURI
| summarize count() by resourceGroupName, resourceGroupURI
| order by tolower(resourceGroupName) asc `;
let resourceGroups: RawAzureResourceGroupItem[] = []; const resourceGroups = await this.azureResourceGraphDatasource.getResourceGroups(subscriptionId, filter);
let allFetched = false;
let $skipToken = undefined;
while (!allFetched) {
// The response may include several pages
let options: Partial<AzureResourceGraphOptions> = {};
if ($skipToken) {
options = {
$skipToken,
};
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureResourceGroupItem[]>(query, 1, options);
resourceGroups = resourceGroups.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
allFetched = !$skipToken;
}
return resourceGroups.map((r) => { return resourceGroups.map((r) => {
const parsedUri = parseResourceURI(r.resourceGroupURI); const parsedUri = parseResourceURI(r.resourceGroupURI);
@ -238,50 +175,20 @@ export default class ResourcePickerData extends DataSourceWithBackend<
}); });
} }
async getResourcesForResourceGroup( // Refactor this one out at a later date
resourceGroupUri: string, async getResourcesForResourceGroup(uri: string, type: ResourcePickerQueryType): Promise<ResourceRowGroup> {
type: ResourcePickerQueryType const resources = await this.azureResourceGraphDatasource.getResourceNames({ uri }, await this.filterByType(type));
): Promise<ResourceRowGroup> {
// We use resource group URI for the filtering here because resource group names are not unique across subscriptions
// We also add a slash at the end of the resource group URI to ensure we do not pull resources from a resource group
// that has a similar naming prefix e.g. resourceGroup1 and resourceGroup10
const query = `
resources
| where id hasprefix "${resourceGroupUri}/"
${await this.filterByType(type)}
| order by tolower(name) asc`;
let resources: RawAzureResourceItem[] = []; return resources.map((resource) => {
let allFetched = false;
let $skipToken = undefined;
while (!allFetched) {
// The response may include several pages
let options: Partial<AzureResourceGraphOptions> = {};
if ($skipToken) {
options = {
$skipToken,
};
}
const resourceResponse = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(query, 1, options);
resources = resources.concat(resourceResponse.data);
$skipToken = resourceResponse.$skipToken;
allFetched = !$skipToken;
}
return resources.map((item) => {
const parsedUri = parseResourceURI(item.id);
if (!parsedUri || !parsedUri.resourceName) {
throw new Error('unable to fetch resource details');
}
return { return {
name: item.name, name: resource.name,
id: parsedUri.resourceName, id: resource.name,
uri: item.id, uri: resource.id,
resourceGroupName: item.resourceGroup, resourceGroupName: resource.resourceGroup,
type: ResourceRowType.Resource, type: ResourceRowType.Resource,
typeLabel: resourceTypeDisplayNames[item.type] || item.type, typeLabel: resourceTypeDisplayNames[resource.type] || resource.type,
locationDisplayName: item.location, locationDisplayName: resource.location,
location: item.location, location: resource.location,
}; };
}); });
} }
@ -321,7 +228,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<
| project subscriptionName, resourceGroupName, resourceName | project subscriptionName, resourceGroupName, resourceName
`; `;
const { data: response } = await this.makeResourceGraphRequest<AzureResourceSummaryItem[]>(query); const response = await this.azureResourceGraphDatasource.pagedResourceGraphRequest<AzureResourceSummaryItem>(query);
if (!response.length) { if (!response.length) {
throw new Error('unable to fetch resource details'); throw new Error('unable to fetch resource details');
@ -339,7 +246,7 @@ export default class ResourcePickerData extends DataSourceWithBackend<
} }
async getResourceURIFromWorkspace(workspace: string) { async getResourceURIFromWorkspace(workspace: string) {
const { data: response } = await this.makeResourceGraphRequest<RawAzureResourceItem[]>(` const response = await this.azureResourceGraphDatasource.pagedResourceGraphRequest<RawAzureResourceItem>(`
resources resources
| where properties['customerId'] == "${workspace}" | where properties['customerId'] == "${workspace}"
| project id | project id
@ -352,28 +259,6 @@ export default class ResourcePickerData extends DataSourceWithBackend<
return response[0].id; return response[0].id;
} }
async makeResourceGraphRequest<T = unknown>(
query: string,
maxRetries = 1,
reqOptions?: Partial<AzureResourceGraphOptions>
): Promise<AzureGraphResponse<T>> {
try {
return await this.postResource(this.resourcePath + RESOURCE_GRAPH_URL, {
query: query,
options: {
resultFormat: 'objectArray',
...reqOptions,
},
});
} catch (error) {
if (maxRetries > 0) {
return this.makeResourceGraphRequest(query, maxRetries - 1);
}
throw error;
}
}
private filterByType = async (t: ResourcePickerQueryType) => { private filterByType = async (t: ResourcePickerQueryType) => {
if (this.supportedMetricNamespaces === '' && t !== 'logs') { if (this.supportedMetricNamespaces === '' && t !== 'logs') {
await this.fetchAllNamespaces(); await this.fetchAllNamespaces();

View File

@ -145,11 +145,14 @@ export interface AzureResourceSummaryItem {
export interface RawAzureSubscriptionItem { export interface RawAzureSubscriptionItem {
subscriptionName: string; subscriptionName: string;
subscriptionId: string; subscriptionId: string;
subscriptionURI: string;
count: number;
} }
export interface RawAzureResourceGroupItem { export interface RawAzureResourceGroupItem {
resourceGroupURI: string; resourceGroupURI: string;
resourceGroupName: string; resourceGroupName: string;
count: number;
} }
export interface RawAzureResourceItem { export interface RawAzureResourceItem {
@ -226,10 +229,11 @@ export interface LegacyAzureGetMetricMetadataQuery {
} }
export interface AzureGetResourceNamesQuery { export interface AzureGetResourceNamesQuery {
subscriptionId: string; subscriptionId?: string;
resourceGroup?: string; resourceGroup?: string;
metricNamespace?: string; metricNamespace?: string;
region?: string; region?: string;
uri?: string;
} }
export interface AzureMonitorLocations { export interface AzureMonitorLocations {

View File

@ -91,7 +91,7 @@ describe('VariableSupport', () => {
}); });
it('can fetch resourceNames with a subscriptionId', async () => { it('can fetch resourceNames with a subscriptionId', async () => {
const expectedResults = ['test']; const expectedResults = [{ name: 'test' }];
const variableSupport = new VariableSupport( const variableSupport = new VariableSupport(
createMockDatasource({ createMockDatasource({
getResourceNames: jest.fn().mockResolvedValueOnce(expectedResults), getResourceNames: jest.fn().mockResolvedValueOnce(expectedResults),
@ -113,7 +113,7 @@ describe('VariableSupport', () => {
], ],
} as DataQueryRequest<AzureMonitorQuery>; } as DataQueryRequest<AzureMonitorQuery>;
const result = await lastValueFrom(variableSupport.query(mockRequest)); const result = await lastValueFrom(variableSupport.query(mockRequest));
expect(result.data[0].fields[0].values).toEqual(expectedResults); expect(result.data[0].fields[0].values).toEqual([expectedResults[0].name]);
}); });
it('can fetch a metricNamespace with a subscriptionId', async () => { it('can fetch a metricNamespace with a subscriptionId', async () => {
@ -448,7 +448,7 @@ describe('VariableSupport', () => {
}); });
it('can fetch resource names', async () => { it('can fetch resource names', async () => {
const expectedResults = ['test']; const expectedResults = [{ name: 'test' }];
const variableSupport = new VariableSupport( const variableSupport = new VariableSupport(
createMockDatasource({ createMockDatasource({
getResourceNames: jest.fn().mockResolvedValueOnce(expectedResults), getResourceNames: jest.fn().mockResolvedValueOnce(expectedResults),
@ -464,7 +464,7 @@ describe('VariableSupport', () => {
], ],
} as DataQueryRequest<AzureMonitorQuery>; } as DataQueryRequest<AzureMonitorQuery>;
const result = await lastValueFrom(variableSupport.query(mockRequest)); const result = await lastValueFrom(variableSupport.query(mockRequest));
expect(result.data[0].fields[0].values).toEqual(expectedResults); expect(result.data[0].fields[0].values).toEqual([expectedResults[0].name]);
}); });
it('returns no data if calling resourceNames but the subscription is a template variable with no value', async () => { it('returns no data if calling resourceNames but the subscription is a template variable with no value', async () => {

View File

@ -1,3 +1,4 @@
import { startsWith } from 'lodash';
import { from, lastValueFrom, Observable } from 'rxjs'; import { from, lastValueFrom, Observable } from 'rxjs';
import { import {
@ -13,10 +14,26 @@ import UrlBuilder from './azure_monitor/url_builder';
import VariableEditor from './components/VariableEditor/VariableEditor'; import VariableEditor from './components/VariableEditor/VariableEditor';
import DataSource from './datasource'; import DataSource from './datasource';
import { migrateQuery } from './grafanaTemplateVariableFns'; import { migrateQuery } from './grafanaTemplateVariableFns';
import { AzureMonitorQuery, AzureQueryType } from './types'; import { AzureMonitorQuery, AzureQueryType, RawAzureResourceItem } from './types';
import { GrafanaTemplateVariableQuery } from './types/templateVariables'; import { GrafanaTemplateVariableQuery } from './types/templateVariables';
import messageFromError from './utils/messageFromError'; import messageFromError from './utils/messageFromError';
export function parseResourceNamesAsTemplateVariable(resources: RawAzureResourceItem[], metricNamespace?: string) {
return resources.map((r) => {
if (startsWith(metricNamespace?.toLowerCase(), 'microsoft.storage/storageaccounts/')) {
return {
text: r.name + '/default',
value: r.name + '/default',
};
}
return {
text: r.name,
value: r.name,
};
});
}
export class VariableSupport extends CustomVariableSupport<DataSource, AzureMonitorQuery> { export class VariableSupport extends CustomVariableSupport<DataSource, AzureMonitorQuery> {
constructor( constructor(
private readonly datasource: DataSource, private readonly datasource: DataSource,
@ -67,14 +84,14 @@ export class VariableSupport extends CustomVariableSupport<DataSource, AzureMoni
return { data: [] }; return { data: [] };
case AzureQueryType.ResourceNamesQuery: case AzureQueryType.ResourceNamesQuery:
if (queryObj.subscription && this.hasValue(queryObj.subscription)) { if (queryObj.subscription && this.hasValue(queryObj.subscription)) {
const rgs = await this.datasource.getResourceNames( const resources = await this.datasource.getResourceNames(
queryObj.subscription, queryObj.subscription,
queryObj.resourceGroup, queryObj.resourceGroup,
queryObj.namespace, queryObj.namespace,
queryObj.region queryObj.region
); );
return { return {
data: rgs?.length ? [toDataFrame(rgs)] : [], data: resources?.length ? [toDataFrame(parseResourceNamesAsTemplateVariable(resources))] : [],
}; };
} }
return { data: [] }; return { data: [] };
@ -201,15 +218,28 @@ export class VariableSupport extends CustomVariableSupport<DataSource, AzureMoni
} }
if (query.kind === 'ResourceGroupsQuery') { if (query.kind === 'ResourceGroupsQuery') {
return this.datasource.getResourceGroups(this.replaceVariable(query.subscription)); return this.datasource.getResourceGroups(this.replaceVariable(query.subscription)).then((rgs) => {
if (rgs.length > 0) {
return rgs.map((rg) => ({ text: rg.resourceGroupName, value: rg.resourceGroupName }));
}
return [];
});
} }
if (query.kind === 'ResourceNamesQuery') { if (query.kind === 'ResourceNamesQuery') {
return this.datasource.getResourceNames( return this.datasource
this.replaceVariable(query.subscription), .getResourceNames(
this.replaceVariable(query.resourceGroup), this.replaceVariable(query.subscription),
this.replaceVariable(query.metricNamespace) this.replaceVariable(query.resourceGroup),
); this.replaceVariable(query.metricNamespace)
)
.then((resources) => {
if (resources.length > 0) {
return parseResourceNamesAsTemplateVariable(resources, query.metricNamespace);
}
return [];
});
} }
if (query.kind === 'MetricNamespaceQuery') { if (query.kind === 'MetricNamespaceQuery') {