├── docs ├── architecture.jpg ├── powerbi-transformdata.jpg ├── powerbi-dashboard-overview.jpg ├── powerbi-datasourcesettings.jpg ├── powerbi-transformdatamenu.jpg ├── workbooks-policycompliance.jpg ├── powerbi-dashboard-costoverview.jpg ├── workbooks-costsgrowing-anomalies.jpg ├── workbooks-resourcesinventory-vms.jpg ├── powerbi-dashboard-fitscorehistory.jpg ├── workbooks-identitiesroles-summary.jpg ├── workbooks-recommendations-overview.jpg ├── loganalytics-additionalperfworkspaces.jpg ├── powerbi-dashboard-vmrightsizeoverview.jpg ├── powerbi-recdetails-recommendationid.jpg ├── workbooks-benefitsusage-reservations.jpg ├── workbooks-blockblobusage-standardv2.jpg ├── workbooks-identitiesroles-rolehistory.jpg ├── workbooks-recommendations-costoverview.jpg ├── suppressing-recommendations.md └── configuring-workspaces.md ├── views ├── AzureOptimizationEngine.pbix ├── workbooks │ ├── costs-growing.bicep │ ├── benefits-usage.bicep │ ├── recommendations.bicep │ ├── identities-roles.bicep │ ├── policy-compliance.bicep │ ├── reservations-usage.bicep │ ├── savingsplans-usage.bicep │ ├── benefits-simulation.bicep │ ├── resources-inventory.bicep │ ├── blockblobstorage-usage.bicep │ └── reservations-potential.bicep └── powerbi-query.m ├── .gitignore ├── model ├── sqlserveringestcontrol-initialize.sql ├── loganalyticsingestcontrol-upgrade.sql ├── sqlserveringestcontrol-table.sql ├── filters-table.sql ├── recommendations-sp.sql ├── loganalyticsingestcontrol-table.sql ├── recommendations-table.sql └── loganalyticsingestcontrol-initialize.sql ├── LICENSE ├── README.md ├── queries ├── rbac-users-roles-guests.kql ├── rbac-users-roles-aad-all.kql ├── rbac-users-roles-arm-privileged.kql ├── rbac-users-roles-guests-privileged.kql ├── rbac-spns-roles-aad-all.kql ├── rbac-spns-roles-arm-all.kql ├── rbac-spns-roles-arm-privileged.kql └── rbac-spns-keys-expiring.kql ├── runbooks ├── maintenance │ └── CleanUp-OlderRecommendationsFromSqlServer.ps1 ├── data-collection │ ├── Export-ReservationsPriceToBlobStorage.ps1 │ ├── Export-ARGAvailabilitySetPropertiesToBlobStorage.ps1 │ ├── Export-ARGSqlDatabasePropertiesToBlobStorage.ps1 │ ├── Export-ARGAppServicePlanPropertiesToBlobStorage.ps1 │ ├── Export-ARGManagedDisksPropertiesToBlobStorage.ps1 │ ├── Export-ARGLoadBalancerPropertiesToBlobStorage.ps1 │ ├── Export-ARGUnmanagedDisksPropertiesToBlobStorage.ps1 │ ├── Export-ARGNSGPropertiesToBlobStorage.ps1 │ ├── Export-ARGAppGatewayPropertiesToBlobStorage.ps1 │ ├── Export-ARGNICPropertiesToBlobStorage.ps1 │ └── Export-AdvisorRecommendationsToBlobStorage.ps1 ├── recommendations │ └── Ingest-SuppressionsToLogAnalytics.ps1 └── remediations │ └── Remediate-AdvisorRightSizeFiltered.ps1 ├── .github └── workflows │ ├── continuous-deployment.yml │ ├── continuous-deployment-dev-new.yml │ └── continuous-deployment-dev.yml ├── perfcounters.json ├── azuredeploy.bicep ├── Setup-LogAnalyticsWorkspaces.ps1 └── Suppress-Recommendation.ps1 /docs/architecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/architecture.jpg -------------------------------------------------------------------------------- /docs/powerbi-transformdata.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-transformdata.jpg -------------------------------------------------------------------------------- /docs/powerbi-dashboard-overview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-dashboard-overview.jpg -------------------------------------------------------------------------------- /docs/powerbi-datasourcesettings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-datasourcesettings.jpg -------------------------------------------------------------------------------- /docs/powerbi-transformdatamenu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-transformdatamenu.jpg -------------------------------------------------------------------------------- /docs/workbooks-policycompliance.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-policycompliance.jpg -------------------------------------------------------------------------------- /views/AzureOptimizationEngine.pbix: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/views/AzureOptimizationEngine.pbix -------------------------------------------------------------------------------- /docs/powerbi-dashboard-costoverview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-dashboard-costoverview.jpg -------------------------------------------------------------------------------- /docs/workbooks-costsgrowing-anomalies.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-costsgrowing-anomalies.jpg -------------------------------------------------------------------------------- /docs/workbooks-resourcesinventory-vms.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-resourcesinventory-vms.jpg -------------------------------------------------------------------------------- /docs/powerbi-dashboard-fitscorehistory.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-dashboard-fitscorehistory.jpg -------------------------------------------------------------------------------- /docs/workbooks-identitiesroles-summary.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-identitiesroles-summary.jpg -------------------------------------------------------------------------------- /docs/workbooks-recommendations-overview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-recommendations-overview.jpg -------------------------------------------------------------------------------- /docs/loganalytics-additionalperfworkspaces.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/loganalytics-additionalperfworkspaces.jpg -------------------------------------------------------------------------------- /docs/powerbi-dashboard-vmrightsizeoverview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-dashboard-vmrightsizeoverview.jpg -------------------------------------------------------------------------------- /docs/powerbi-recdetails-recommendationid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/powerbi-recdetails-recommendationid.jpg -------------------------------------------------------------------------------- /docs/workbooks-benefitsusage-reservations.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-benefitsusage-reservations.jpg -------------------------------------------------------------------------------- /docs/workbooks-blockblobusage-standardv2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-blockblobusage-standardv2.jpg -------------------------------------------------------------------------------- /docs/workbooks-identitiesroles-rolehistory.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-identitiesroles-rolehistory.jpg -------------------------------------------------------------------------------- /docs/workbooks-recommendations-costoverview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/HEAD/docs/workbooks-recommendations-costoverview.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Deployment state file 2 | last-deployment-state.json 3 | # Database connection settings (for the Suppress-Recommendation.ps1 helper script) 4 | database-connection-settings.json 5 | # Silent deployment settings file 6 | deployment-settings-*.json -------------------------------------------------------------------------------- /model/sqlserveringestcontrol-initialize.sql: -------------------------------------------------------------------------------- 1 | IF NOT EXISTS (SELECT * FROM [dbo].[SqlServerIngestControl] WHERE StorageContainerName = 'recommendationsexports') 2 | BEGIN 3 | INSERT INTO [dbo].[SqlServerIngestControl] 4 | VALUES 5 | ('recommendationsexports', '1901-01-01T00:00:00Z', -1, 'Recommendations') 6 | END -------------------------------------------------------------------------------- /model/loganalyticsingestcontrol-upgrade.sql: -------------------------------------------------------------------------------- 1 | UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'ARGVirtualMachine' WHERE StorageContainerName = 'argvmexports' 2 | UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'ARGManagedDisk' WHERE StorageContainerName = 'argdiskexports' 3 | UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'AzureAdvisor' WHERE StorageContainerName = 'advisorexports' 4 | UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'RemediationLogs' WHERE StorageContainerName = 'remediationlogs' 5 | UPDATE [dbo].[LogAnalyticsIngestControl] SET CollectedType = 'AzureConsumption' WHERE StorageContainerName = 'consumptionexports' -------------------------------------------------------------------------------- /model/sqlserveringestcontrol-table.sql: -------------------------------------------------------------------------------- 1 | SET ANSI_NULLS ON 2 | SET QUOTED_IDENTIFIER ON 3 | IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[SqlServerIngestControl]') 4 | AND OBJECTPROPERTY(id, N'IsUserTable') = 1) 5 | BEGIN 6 | CREATE TABLE [dbo].[SqlServerIngestControl]( 7 | [StorageContainerName] [varchar](50) NOT NULL, 8 | [LastProcessedDateTime] [datetime] NULL, 9 | [LastProcessedLine] [int] NULL, 10 | [SqlTableName] [varchar](50) NOT NULL 11 | ) 12 | ALTER TABLE [dbo].[SqlServerIngestControl] ADD PRIMARY KEY CLUSTERED 13 | ( 14 | [StorageContainerName] ASC 15 | )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] 16 | END -------------------------------------------------------------------------------- /model/filters-table.sql: -------------------------------------------------------------------------------- 1 | SET ANSI_NULLS ON 2 | SET QUOTED_IDENTIFIER ON 3 | IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[Filters]') AND OBJECTPROPERTY(id, N'IsUserTable') = 1) 4 | BEGIN 5 | CREATE TABLE [dbo].[Filters]( 6 | [FilterId] [uniqueidentifier] NOT NULL DEFAULT NEWID(), 7 | [RecommendationSubTypeId] [uniqueidentifier] NOT NULL, 8 | [FilterType] [varchar](20) NOT NULL, 9 | [InstanceId] [varchar](1000) NULL, 10 | [FilterStartDate] [datetime] NOT NULL, 11 | [FilterEndDate] [datetime] NULL, 12 | [Author] [varchar](50) NULL, 13 | [Notes] [nvarchar](max) NULL, 14 | [IsEnabled] [bit] NOT NULL 15 | ) 16 | 17 | ALTER TABLE [dbo].[Filters] ADD PRIMARY KEY CLUSTERED 18 | ( 19 | [FilterId] ASC 20 | )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] 21 | END 22 | -------------------------------------------------------------------------------- /model/recommendations-sp.sql: -------------------------------------------------------------------------------- 1 | IF OBJECT_ID ( N'[dbo].[GetRecommendations]', 'P' ) IS NOT NULL 2 | BEGIN 3 | DROP PROCEDURE dbo.GetRecommendations 4 | END 5 | EXEC('CREATE PROCEDURE dbo.GetRecommendations 6 | AS BEGIN 7 | SET NOCOUNT ON; 8 | SELECT * FROM [dbo].[Recommendations] R 9 | WHERE GeneratedDate > GETDATE()-365 AND NOT EXISTS ( 10 | SELECT * FROM [dbo].[Filters] 11 | WHERE FilterType IN (''Snooze'', ''Dismiss'') AND 12 | IsEnabled = 1 AND 13 | R.GeneratedDate > FilterStartDate AND 14 | (FilterEndDate IS NULL OR FilterEndDate > GETDATE()) AND 15 | RecommendationSubTypeId = R.RecommendationSubTypeId AND 16 | (InstanceId IS NULL OR R.InstanceId LIKE ''%'' + InstanceId + ''%'') 17 | ) 18 | END 19 | ') 20 | -------------------------------------------------------------------------------- /model/loganalyticsingestcontrol-table.sql: -------------------------------------------------------------------------------- 1 | SET ANSI_NULLS ON 2 | SET QUOTED_IDENTIFIER ON 3 | IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[LogAnalyticsIngestControl]') 4 | AND OBJECTPROPERTY(id, N'IsUserTable') = 1) 5 | BEGIN 6 | CREATE TABLE [dbo].[LogAnalyticsIngestControl]( 7 | [StorageContainerName] [varchar](50) NOT NULL, 8 | [LastProcessedDateTime] [datetime] NULL, 9 | [LastProcessedLine] [int] NULL, 10 | [LogAnalyticsSuffix] [varchar](50) NOT NULL, 11 | [CollectedType] [varchar](50) NULL 12 | ) 13 | 14 | ALTER TABLE [dbo].[LogAnalyticsIngestControl] ADD PRIMARY KEY CLUSTERED 15 | ( 16 | [StorageContainerName] ASC 17 | )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] 18 | END 19 | ELSE 20 | BEGIN 21 | IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[LogAnalyticsIngestControl]') AND name = 'CollectedType' 22 | ) BEGIN 23 | ALTER TABLE [dbo].[LogAnalyticsIngestControl] ADD [CollectedType] VARCHAR (50) NULL 24 | END 25 | END -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hélder Pinto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/workbooks/costs-growing.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Costs Growing' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '81afe6eb-8e9e-4315-811c-89b5de245c9a' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('costs-growing.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/benefits-usage.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Benefits Usage' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '4730b9ab-9f13-4c28-a999-bcce218b283d' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('benefits-usage.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/recommendations.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Recommendations' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '5b6ec066-e5a8-463e-a319-919c0a7d7bb6' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('recommendations.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/identities-roles.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Identities and Roles' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '4946ffbe-16c1-4a32-81a4-8ed024278ab2' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('identities-roles.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/policy-compliance.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Policy Compliance' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '8fceeb3c-4c7a-4ba9-b97b-a6d9fc8dd6aa' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('policy-compliance.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/reservations-usage.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Reservations Usage' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '5fc75aa1-db43-4938-bbea-90dcb71ef5a2' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('reservations-usage.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/savingsplans-usage.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Savings Plans Usage' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = 'a4a4bb1e-0a20-45b8-ab47-4bc38f9cc22e' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('savingsplans-usage.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/benefits-simulation.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Benefits Simulation' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '96fabefe-1f3e-4526-a5db-c442661617e5' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('benefits-simulation.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/resources-inventory.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Resources Inventory' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '065fc198-6435-4724-99b2-60cea8a2d7d2' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('resources-inventory.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/blockblobstorage-usage.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Block Blob Storage Usage' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '871eb144-c8bb-4824-90c3-f84fe197933a' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('blockblobstorage-usage.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /views/workbooks/reservations-potential.bicep: -------------------------------------------------------------------------------- 1 | @description('The friendly name for the workbook that is used in the Gallery or Saved List. This name must be unique within a resource group.') 2 | param workbookDisplayName string = 'Reservations Potential' 3 | 4 | @description('The gallery that the workbook will been shown under. Supported values include workbook, tsg, etc. Usually, this is \'workbook\'') 5 | param workbookType string = 'workbook' 6 | 7 | @description('The id of resource instance to which the workbook will be associated') 8 | param workbookSourceId string 9 | 10 | @description('The unique guid for this workbook instance') 11 | param workbookId string = '14707f9b-03c4-43ff-9811-2b2cc1c74b61' 12 | param resourceTags object 13 | 14 | param resourceGroupLocation string = resourceGroup().location 15 | 16 | resource workbookId_resource 'microsoft.insights/workbooks@2022-04-01' = { 17 | name: workbookId 18 | location: resourceGroupLocation 19 | tags: resourceTags 20 | kind: 'shared' 21 | properties: { 22 | displayName: workbookDisplayName 23 | serializedData: string(loadJsonContent('reservations-potential.json')) 24 | version: '1.0' 25 | sourceId: workbookSourceId 26 | category: workbookType 27 | } 28 | dependsOn: [] 29 | } 30 | 31 | output workbookId string = workbookId_resource.id 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to the Azure Optimization Engine - now a FinOps Toolkit tool! 🔍 2 | 3 | 👋 Thank you for your interest in the Azure Optimization Engine! We've migrated the Azure Optimization Engine repository to a new home - the Microsoft [FinOps Toolkit](https://aka.ms/AzureOptimizationEngine). For historical reasons, the older code will remain here, but the most recent version and new releases will be made in the FinOps Toolkit repository going forward. Here's what you need to know: 4 | 5 | 1. **We are collecting feedback in preparation for AOE v2**: Answer this [anonymous feedback form](https://forms.office.com/r/fLeJS8Rd2E) and contribute to the [discussion about the evolution of AOE](https://github.com/microsoft/finops-toolkit/discussions/753). 6 | 7 | 1. **Start Using the FinOps Toolkit**: If you were using the Azure Optimization Engine, it's time to switch! The [FinOps Toolkit](https://aka.ms/AzureOptimizationEngine) provides advanced solutions, automation scripts, and learning resources to accelerate your FinOps journey. 8 | 9 | 2. **Open Issues Here**: Have questions, feedback, or need assistance? Open an issue in the [FinOps Toolkit repository](https://github.com/microsoft/finops-toolkit/issues), and our community and maintainers will be happy to help. 🙌 10 | 11 | 3. **Explore Our Tools**: Besides the Azure Optimization Engine, check out all the other available tools, including FinOps hubs, Power BI reports, cost optimization workbooks, and more. [Explore the FinOps Toolkit](https://aka.ms/finops/toolkit) 12 | 13 | 4. **Stay Updated**: Follow our [FinOps blog](https://techcommunity.microsoft.com/t5/finops-blog/bg-p/FinOpsBlog) for the latest news and announcements. 14 | 15 | Let's make cloud cost management easier together! 🌟 16 | -------------------------------------------------------------------------------- /queries/rbac-users-roles-guests.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let EnabledGuestUsers = materialize(AADObjectsTable 4 | | where isnotempty(ObjectId_g) 5 | | where ObjectType_s == 'User' and ObjectSubType_s == 'Guest' and SecurityEnabled_s == 'True' 6 | | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); 7 | let GroupMemberships = AADObjectsTable 8 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 9 | | where PrincipalNames_s startswith '[' 10 | | extend GroupMember = parse_json(PrincipalNames_s) 11 | | project-away PrincipalNames_s 12 | | mv-expand GroupMember 13 | | union ( 14 | AADObjectsTable 15 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 16 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 17 | | extend GroupMember = parse_json(PrincipalNames_s) 18 | | project-away PrincipalNames_s 19 | ) 20 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 21 | let DirectUserAssignments = RBACAssignmentsTable 22 | | join kind=inner ( 23 | EnabledGuestUsers 24 | ) on $left.PrincipalId_g == $right.UserId 25 | | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; 26 | let GroupUserAssignments = RBACAssignmentsTable 27 | | join kind=inner ( 28 | GroupMemberships 29 | | join kind=inner ( 30 | EnabledGuestUsers 31 | ) on $left.GroupMember == $right.UserId 32 | ) on $left.PrincipalId_g == $right.GroupId 33 | | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; 34 | GroupUserAssignments 35 | | union DirectUserAssignments 36 | | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g 37 | | order by PrincipalNames_s asc -------------------------------------------------------------------------------- /queries/rbac-users-roles-aad-all.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let EnabledUsers = materialize(AADObjectsTable 4 | | where isnotempty(ObjectId_g) 5 | | where ObjectType_s == 'User' and SecurityEnabled_s == 'True' 6 | | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); 7 | let GroupMemberships = AADObjectsTable 8 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 9 | | where PrincipalNames_s startswith '[' 10 | | extend GroupMember = parse_json(PrincipalNames_s) 11 | | project-away PrincipalNames_s 12 | | mv-expand GroupMember 13 | | union ( 14 | AADObjectsTable 15 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 16 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 17 | | extend GroupMember = parse_json(PrincipalNames_s) 18 | | project-away PrincipalNames_s 19 | ) 20 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 21 | let DirectUserAssignments = RBACAssignmentsTable 22 | | where Model_s == 'AzureAD' 23 | | join kind=inner ( 24 | EnabledUsers 25 | ) on $left.PrincipalId_g == $right.UserId 26 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; 27 | let GroupUserAssignments = RBACAssignmentsTable 28 | | where Model_s == 'AzureAD' 29 | | join kind=inner ( 30 | GroupMemberships 31 | | join kind=inner ( 32 | EnabledUsers 33 | ) on $left.GroupMember == $right.UserId 34 | ) on $left.PrincipalId_g == $right.GroupId 35 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; 36 | GroupUserAssignments 37 | | union DirectUserAssignments 38 | | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g 39 | | order by PrincipalNames_s asc -------------------------------------------------------------------------------- /runbooks/maintenance/CleanUp-OlderRecommendationsFromSqlServer.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" 4 | $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" 5 | $SqlUsername = $sqlserverCredential.UserName 6 | $SqlPass = $sqlserverCredential.GetNetworkCredential().Password 7 | $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue 8 | if ([string]::IsNullOrEmpty($sqldatabase)) 9 | { 10 | $sqldatabase = "azureoptimization" 11 | } 12 | $RecommendationsMaxAge = [int] (Get-AutomationVariable -Name "AzureOptimization_RecommendationsMaxAgeInDays" -ErrorAction SilentlyContinue) 13 | if (-not($RecommendationsMaxAge -gt 0)) 14 | { 15 | $RecommendationsMaxAge = 365 16 | } 17 | 18 | $recommendationsTable = "Recommendations" 19 | 20 | $tries = 0 21 | $connectionSuccess = $false 22 | 23 | Write-Output "Cleaning up recommendations older than $RecommendationsMaxAge days..." 24 | 25 | do { 26 | $tries++ 27 | try { 28 | $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") 29 | $Conn.Open() 30 | $Cmd=new-object system.Data.SqlClient.SqlCommand 31 | $Cmd.Connection = $Conn 32 | $Cmd.CommandTimeout = 0 33 | $Cmd.CommandText = "DELETE FROM [dbo].[$recommendationsTable] WHERE GeneratedDate < GETDATE()-$RecommendationsMaxAge" 34 | $DeletedRows = $Cmd.ExecuteNonQuery() 35 | $connectionSuccess = $true 36 | } 37 | catch { 38 | Write-Output "Failed to contact SQL at try $tries." 39 | Write-Output $Error[0] 40 | Start-Sleep -Seconds ($tries * 20) 41 | } 42 | finally { 43 | $Conn.Close() 44 | $Conn.Dispose() 45 | } 46 | } while (-not($connectionSuccess) -and $tries -lt 3) 47 | 48 | if (-not($connectionSuccess)) 49 | { 50 | throw "Could not establish connection to SQL." 51 | } 52 | 53 | Write-Output "Cleaned up $DeletedRows recommendations." -------------------------------------------------------------------------------- /queries/rbac-users-roles-arm-privileged.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let PrivilegedRoles = dynamic(['Owner','Contributor']); 4 | let EnabledUsers = materialize(AADObjectsTable 5 | | where isnotempty(ObjectId_g) 6 | | where ObjectType_s == 'User' and SecurityEnabled_s == 'True' 7 | | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); 8 | let GroupMemberships = AADObjectsTable 9 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 10 | | where PrincipalNames_s startswith '[' 11 | | extend GroupMember = parse_json(PrincipalNames_s) 12 | | project-away PrincipalNames_s 13 | | mv-expand GroupMember 14 | | union ( 15 | AADObjectsTable 16 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 17 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 18 | | extend GroupMember = parse_json(PrincipalNames_s) 19 | | project-away PrincipalNames_s 20 | ) 21 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 22 | let DirectUserAssignments = RBACAssignmentsTable 23 | | where Model_s == 'AzureRM' 24 | | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' 25 | | join kind=inner ( 26 | EnabledUsers 27 | ) on $left.PrincipalId_g == $right.UserId 28 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; 29 | let GroupUserAssignments = RBACAssignmentsTable 30 | | where Model_s == 'AzureRM' 31 | | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' 32 | | join kind=inner ( 33 | GroupMemberships 34 | | join kind=inner ( 35 | EnabledUsers 36 | ) on $left.GroupMember == $right.UserId 37 | ) on $left.PrincipalId_g == $right.GroupId 38 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; 39 | GroupUserAssignments 40 | | union DirectUserAssignments 41 | | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g 42 | | order by PrincipalNames_s asc -------------------------------------------------------------------------------- /.github/workflows/continuous-deployment.yml: -------------------------------------------------------------------------------- 1 | name: AOE Continuous Deployment (PROD) 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - master 7 | permissions: 8 | id-token: write 9 | contents: read 10 | jobs: 11 | AOE-CD: 12 | environment: prod 13 | runs-on: ubuntu-latest 14 | env: 15 | AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 16 | AOE_SQL_ADMIN: ${{ secrets.AOE_SQL_ADMIN }} 17 | AOE_SQL_PASSWD: ${{ secrets.AOE_SQL_PASSWD }} 18 | AOE_LOCATION: ${{ secrets.AOE_LOCATION }} 19 | AOE_NAMEPREFIX: ${{ secrets.AOE_NAMEPREFIX }} 20 | steps: 21 | - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub for the ${{ github.ref }} branch of the ${{ github.repository }} repository!" 22 | - name: Installing modules 23 | shell: pwsh 24 | run: | 25 | Set-PSRepository PSGallery -InstallationPolicy Trusted 26 | Install-Module -Name Az.Accounts,Az.Resources,Az.Storage,Az.OperationalInsights,Az.Sql,Az.Automation,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement -Force 27 | - name: Check out repository code 28 | uses: actions/checkout@v3 29 | - name: Login via Az module 30 | uses: azure/login@hf_447_release 31 | with: 32 | client-id: ${{ secrets.AZURE_CLIENT_ID }} 33 | tenant-id: ${{ secrets.AZURE_TENANT_ID }} 34 | subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 35 | enable-AzPSSession: true 36 | - name: Create Deployment Settings JSON file 37 | run: | 38 | echo '{ 39 | "SubscriptionId": "'"$AZURE_SUBSCRIPTION_ID"'", 40 | "NamePrefix": "'"$AOE_NAMEPREFIX"'", 41 | "WorkspaceReuse": "n", 42 | "DeployWorkbooks": "y", 43 | "SqlAdmin": "'"$AOE_SQL_ADMIN"'", 44 | "SqlPass": "'"$AOE_SQL_PASSWD"'", 45 | "TargetLocation": "'"$AOE_LOCATION"'", 46 | "DeployBenefitsUsageDependencies": "n" 47 | }' > ./deploymentSettings.json 48 | - name: Testing PowerShell script call 49 | shell: pwsh 50 | run: | 51 | ./Deploy-AzureOptimizationEngine.ps1 -SilentDeploymentSettingsPath ./deploymentSettings.json 52 | - run: echo "🍏 This job's status is ${{ job.status }}." 53 | -------------------------------------------------------------------------------- /queries/rbac-users-roles-guests-privileged.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let PrivilegedRoles = dynamic(['Owner','Contributor','Global Administrator', 'Privileged Role Administrator', 'User Access Administrator','Exchange Administrator']); 4 | let EnabledGuestUsers = materialize(AADObjectsTable 5 | | where isnotempty(ObjectId_g) 6 | | where ObjectType_s == 'User' and ObjectSubType_s == 'Guest' and SecurityEnabled_s == 'True' 7 | | project UserId = ObjectId_g, PrincipalNames_s, DisplayName_s); 8 | let GroupMemberships = AADObjectsTable 9 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 10 | | where PrincipalNames_s startswith '[' 11 | | extend GroupMember = parse_json(PrincipalNames_s) 12 | | project-away PrincipalNames_s 13 | | mv-expand GroupMember 14 | | union ( 15 | AADObjectsTable 16 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 17 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 18 | | extend GroupMember = parse_json(PrincipalNames_s) 19 | | project-away PrincipalNames_s 20 | ) 21 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 22 | let DirectUserAssignments = RBACAssignmentsTable 23 | | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' 24 | | join kind=inner ( 25 | EnabledGuestUsers 26 | ) on $left.PrincipalId_g == $right.UserId 27 | | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; 28 | let GroupUserAssignments = RBACAssignmentsTable 29 | | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' 30 | | join kind=inner ( 31 | GroupMemberships 32 | | join kind=inner ( 33 | EnabledGuestUsers 34 | ) on $left.GroupMember == $right.UserId 35 | ) on $left.PrincipalId_g == $right.GroupId 36 | | project DisplayName_s, PrincipalNames_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; 37 | GroupUserAssignments 38 | | union DirectUserAssignments 39 | | distinct DisplayName_s, PrincipalNames_s, RoleDefinition_s, Model_s, Scope_s, Assignment, TenantGuid_g 40 | | order by PrincipalNames_s asc -------------------------------------------------------------------------------- /perfcounters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "objectName": "LogicalDisk", 4 | "instance": "*", 5 | "counterName": "Disk Read Bytes/sec", 6 | "osType": "Windows" 7 | }, 8 | { 9 | "objectName": "LogicalDisk", 10 | "instance": "*", 11 | "counterName": "Disk Reads/sec", 12 | "osType": "Windows" 13 | }, 14 | { 15 | "objectName": "LogicalDisk", 16 | "instance": "*", 17 | "counterName": "Disk Write Bytes/sec", 18 | "osType": "Windows" 19 | }, 20 | { 21 | "objectName": "LogicalDisk", 22 | "instance": "*", 23 | "counterName": "Disk Writes/sec", 24 | "osType": "Windows" 25 | }, 26 | { 27 | "objectName": "Memory", 28 | "instance": "*", 29 | "counterName": "Available MBytes", 30 | "osType": "Windows" 31 | }, 32 | { 33 | "objectName": "Network Adapter", 34 | "instance": "*", 35 | "counterName": "Bytes Total/sec", 36 | "osType": "Windows" 37 | }, 38 | { 39 | "objectName": "Processor", 40 | "instance": "*", 41 | "counterName": "% Processor Time", 42 | "osType": "Windows" 43 | }, 44 | { 45 | "objectName": "Logical Disk", 46 | "instance": "*", 47 | "counterName": "Disk Read Bytes/sec", 48 | "osType": "Linux" 49 | }, 50 | { 51 | "objectName": "Logical Disk", 52 | "instance": "*", 53 | "counterName": "Disk Reads/sec", 54 | "osType": "Linux" 55 | }, 56 | { 57 | "objectName": "Logical Disk", 58 | "instance": "*", 59 | "counterName": "Disk Write Bytes/sec", 60 | "osType": "Linux" 61 | }, 62 | { 63 | "objectName": "Logical Disk", 64 | "instance": "*", 65 | "counterName": "Disk Writes/sec", 66 | "osType": "Linux" 67 | }, 68 | { 69 | "objectName": "Memory", 70 | "instance": "*", 71 | "counterName": "% Used Memory", 72 | "osType": "Linux" 73 | }, 74 | { 75 | "objectName": "Network", 76 | "instance": "*", 77 | "counterName": "Total Bytes", 78 | "osType": "Linux" 79 | }, 80 | { 81 | "objectName": "Processor", 82 | "instance": "*", 83 | "counterName": "% Processor Time", 84 | "osType": "Linux" 85 | } 86 | ] -------------------------------------------------------------------------------- /.github/workflows/continuous-deployment-dev-new.yml: -------------------------------------------------------------------------------- 1 | name: AOE Continuous Deployment (DEV NEW) 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - dev 7 | permissions: 8 | id-token: write 9 | contents: read 10 | jobs: 11 | AOE-CD: 12 | environment: devnew 13 | runs-on: ubuntu-latest 14 | env: 15 | AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 16 | AOE_SQL_ADMIN: ${{ secrets.AOE_SQL_ADMIN }} 17 | AOE_SQL_PASSWD: ${{ secrets.AOE_SQL_PASSWD }} 18 | AOE_LOCATION: ${{ secrets.AOE_LOCATION }} 19 | AOE_NAMEPREFIX: ${{ secrets.AOE_NAMEPREFIX }} 20 | steps: 21 | - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub for the ${{ github.ref }} branch of the ${{ github.repository }} repository!" 22 | - name: Installing modules 23 | shell: pwsh 24 | run: | 25 | Set-PSRepository PSGallery -InstallationPolicy Trusted 26 | Install-Module -Name Az.Accounts,Az.Resources,Az.Storage,Az.OperationalInsights,Az.Sql,Az.Automation,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement -Force 27 | - name: Check out repository code 28 | uses: actions/checkout@v3 29 | - name: Login via Az module 30 | uses: azure/login@hf_447_release 31 | with: 32 | client-id: ${{ secrets.AZURE_CLIENT_ID }} 33 | tenant-id: ${{ secrets.AZURE_TENANT_ID }} 34 | subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 35 | enable-AzPSSession: true 36 | - name: Create Deployment Settings JSON file 37 | run: | 38 | echo '{ 39 | "SubscriptionId": "'"$AZURE_SUBSCRIPTION_ID"'", 40 | "NamePrefix": "'"$AOE_NAMEPREFIX$(date '+%Y%m%d%H')"'", 41 | "WorkspaceReuse": "n", 42 | "DeployWorkbooks": "y", 43 | "SqlAdmin": "'"$AOE_SQL_ADMIN"'", 44 | "SqlPass": "'"$AOE_SQL_PASSWD"'", 45 | "TargetLocation": "'"$AOE_LOCATION"'", 46 | "DeployBenefitsUsageDependencies": "n" 47 | }' > ./deploymentSettings.json 48 | - name: Testing PowerShell script call 49 | shell: pwsh 50 | run: | 51 | ./Deploy-AzureOptimizationEngine.ps1 -SilentDeploymentSettingsPath ./deploymentSettings.json -TemplateUri "https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/dev/azuredeploy.bicep" 52 | - run: echo "🍏 This job's status is ${{ job.status }}." 53 | -------------------------------------------------------------------------------- /docs/suppressing-recommendations.md: -------------------------------------------------------------------------------- 1 | # Suppressing recommendations 2 | 3 | When working on the recommendations provided by AOE, you may find some cases where the recommendation does not apply for some reason. For example, AOE is suggesting high availability recommendations that do not apply to Dev/Test Virtual Machines, or recommending enabling Azure Backup for non-critical VMs. You can suppress recommendations in two ways: 4 | 5 | * If recommendations are originated from Azure Advisor, you can simply go to the Azure Portal and [dismiss/postpone the recommendation](https://docs.microsoft.com/en-us/azure/advisor/view-recommendations#dismissing-and-postponing-recommendations). 6 | * If recommendations are custom to AOE or using the Azure Advisor interface is not viable, you can suppress them in AOE using the [Suppress-Recommendation.ps1](../Suppress-Recommendation.ps1) helper script (see instructions below). 7 | 8 | ## Identifying the recommendation to suppress 9 | 10 | In the Power BI report, if you drill through the details of a recommendation (Rec. Details page), you will find the Recommendation Id in the header. Copy this Id, by using the "Copy value" right-click menu option. You'll need this ID to call the Supress-Recommendation.ps1 script. 11 | 12 | ![Copying the Recommendation Id value from the Recommendation Details page in the Power BI report](./powerbi-recdetails-recommendationid.jpg "Copy the Recommendation Id value") 13 | 14 | ## Supressing the recommendation 15 | 16 | From a PowerShell prompt, call the [Suppress-Recommendation.ps1](../Suppress-Recommendation.ps1) script as follows: 17 | 18 | ```powershell 19 | ./Suppress-Recommendation.ps1 -RecommendationId 20 | 21 | # Example 22 | ./Suppress-Recommendation.ps1 -RecommendationId A2824017-602C-47DF-860D-B0B5A8CA7768 23 | ``` 24 | 25 | The script will ask you for the Azure SQL Server hostname, database and user credentials. After successfully finding the recommendation in the AOE database, it will ask you about the type of suppression: 26 | 27 | * **Exclude** - this recommendation type will be completely excluded from the engine and will no longer be generated for any resource 28 | * **Dismiss** - this recommendation will be dismissed for the scope to be chosen next (instance, resource group or subscription) 29 | * **Snooze** - this recommendation will be postponed for the duration (in days) and scope to be chosen next (instance, resource group or subscription) 30 | 31 | Depending on the type of suppression chosen, you can be asked to provide the suppression scope (subscription, resource group or resource instance) or the suppression duration (for Snooze suppressions). Finally, you should identify the author and the reason for the suppression. -------------------------------------------------------------------------------- /.github/workflows/continuous-deployment-dev.yml: -------------------------------------------------------------------------------- 1 | name: AOE Continuous Deployment (DEV) 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - dev 7 | permissions: 8 | id-token: write 9 | contents: read 10 | jobs: 11 | AOE-CD: 12 | environment: dev 13 | runs-on: ubuntu-latest 14 | env: 15 | AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 16 | AOE_SQL_ADMIN: ${{ secrets.AOE_SQL_ADMIN }} 17 | AOE_SQL_PASSWD: ${{ secrets.AOE_SQL_PASSWD }} 18 | AOE_LOCATION: ${{ secrets.AOE_LOCATION }} 19 | AOE_NAMEPREFIX: ${{ secrets.AOE_NAMEPREFIX }} 20 | AOE_WORKSPACENAME: ${{ secrets.AOE_WORKSPACENAME }} 21 | AOE_WORKSPACERG: ${{ secrets.AOE_WORKSPACERG }} 22 | steps: 23 | - run: echo "This job is now running on a ${{ runner.os }} server hosted by GitHub for the ${{ github.ref }} branch of the ${{ github.repository }} repository!" 24 | - name: Installing modules 25 | shell: pwsh 26 | run: | 27 | Set-PSRepository PSGallery -InstallationPolicy Trusted 28 | Install-Module -Name Az.Accounts,Az.Resources,Az.Storage,Az.OperationalInsights,Az.Sql,Az.Automation,Microsoft.Graph.Authentication,Microsoft.Graph.Identity.DirectoryManagement -Force 29 | - name: Check out repository code 30 | uses: actions/checkout@v3 31 | - name: Login via Az module 32 | uses: azure/login@hf_447_release 33 | with: 34 | client-id: ${{ secrets.AZURE_CLIENT_ID }} 35 | tenant-id: ${{ secrets.AZURE_TENANT_ID }} 36 | subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 37 | enable-AzPSSession: true 38 | - name: Create Deployment Settings JSON file 39 | run: | 40 | echo '{ 41 | "SubscriptionId": "'"$AZURE_SUBSCRIPTION_ID"'", 42 | "NamePrefix": "'"$AOE_NAMEPREFIX"'", 43 | "WorkspaceReuse": "y", 44 | "WorkspaceName": "'"$AOE_WORKSPACENAME"'", 45 | "WorkspaceResourceGroupName": "'"$AOE_WORKSPACERG"'", 46 | "DeployWorkbooks": "y", 47 | "SqlAdmin": "'"$AOE_SQL_ADMIN"'", 48 | "SqlPass": "'"$AOE_SQL_PASSWD"'", 49 | "TargetLocation": "'"$AOE_LOCATION"'", 50 | "DeployBenefitsUsageDependencies": "n" 51 | }' > ./deploymentSettings.json 52 | - name: Testing PowerShell script call 53 | shell: pwsh 54 | run: | 55 | ./Deploy-AzureOptimizationEngine.ps1 -SilentDeploymentSettingsPath ./deploymentSettings.json -TemplateUri "https://raw.githubusercontent.com/helderpinto/AzureOptimizationEngine/dev/azuredeploy.bicep" -DoPartialUpgrade 56 | - run: echo "🍏 This job's status is ${{ job.status }}." 57 | -------------------------------------------------------------------------------- /queries/rbac-spns-roles-aad-all.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let AppsAndKeys = materialize (AADObjectsTable 4 | | where ObjectType_s in ('Application','ServicePrincipal') 5 | | where Keys_s startswith '[' 6 | | extend Keys = parse_json(Keys_s) 7 | | project-away Keys_s 8 | | mv-expand Keys 9 | | evaluate bag_unpack(Keys) 10 | | union ( 11 | AADObjectsTable 12 | | where ObjectType_s in ('Application','ServicePrincipal') 13 | | where isnotempty(Keys_s) and Keys_s !startswith '[' 14 | | extend Keys = parse_json(Keys_s) 15 | | project-away Keys_s 16 | | evaluate bag_unpack(Keys) 17 | ) 18 | ); 19 | let ServicePrincipals = materialize(AADObjectsTable 20 | | where isnotempty(ObjectId_g) 21 | | where ObjectType_s == 'ServicePrincipal' 22 | | join kind=inner ( 23 | AppsAndKeys 24 | ) on ApplicationId_g 25 | | project SPNId = ObjectId_g, PrincipalNames_s, DisplayName_s, KeyType, EndDate); 26 | let GroupMemberships = AADObjectsTable 27 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 28 | | where PrincipalNames_s startswith '[' 29 | | extend GroupMember = parse_json(PrincipalNames_s) 30 | | project-away PrincipalNames_s 31 | | mv-expand GroupMember 32 | | union ( 33 | AADObjectsTable 34 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 35 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 36 | | extend GroupMember = parse_json(PrincipalNames_s) 37 | | project-away PrincipalNames_s 38 | ) 39 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 40 | let DirectAssignments = RBACAssignmentsTable 41 | | where Model_s == 'AzureAD' 42 | | join kind=inner ( 43 | ServicePrincipals 44 | ) on $left.PrincipalId_g == $right.SPNId 45 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', KeyType, EndDate, Model_s, TenantGuid_g; 46 | let GroupAssignments = RBACAssignmentsTable 47 | | where Model_s == 'AzureAD' 48 | | join kind=inner ( 49 | GroupMemberships 50 | | join kind=inner ( 51 | ServicePrincipals 52 | ) on $left.GroupMember == $right.SPNId 53 | ) on $left.PrincipalId_g == $right.GroupId 54 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), KeyType, EndDate, Model_s, TenantGuid_g; 55 | GroupAssignments 56 | | union DirectAssignments 57 | | distinct DisplayName_s, RoleDefinition_s, Model_s, Scope_s, Assignment, KeyType, EndDate, PrincipalNames_s, TenantGuid_g 58 | | where EndDate > now() 59 | | order by DisplayName_s asc -------------------------------------------------------------------------------- /queries/rbac-spns-roles-arm-all.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let AppsAndKeys = materialize (AADObjectsTable 4 | | where ObjectType_s in ('Application','ServicePrincipal') 5 | | where Keys_s startswith '[' 6 | | extend Keys = parse_json(Keys_s) 7 | | project-away Keys_s 8 | | mv-expand Keys 9 | | evaluate bag_unpack(Keys) 10 | | union ( 11 | AADObjectsTable 12 | | where ObjectType_s in ('Application','ServicePrincipal') 13 | | where isnotempty(Keys_s) and Keys_s !startswith '[' 14 | | extend Keys = parse_json(Keys_s) 15 | | project-away Keys_s 16 | | evaluate bag_unpack(Keys) 17 | ) 18 | ); 19 | let ServicePrincipals = materialize(AADObjectsTable 20 | | where isnotempty(ObjectId_g) 21 | | where ObjectType_s == 'ServicePrincipal' 22 | | join kind=inner ( 23 | AppsAndKeys 24 | ) on ApplicationId_g 25 | | project SPNId = ObjectId_g, PrincipalNames_s, DisplayName_s, KeyType, EndDate); 26 | let GroupMemberships = AADObjectsTable 27 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 28 | | where PrincipalNames_s startswith '[' 29 | | extend GroupMember = parse_json(PrincipalNames_s) 30 | | project-away PrincipalNames_s 31 | | mv-expand GroupMember 32 | | union ( 33 | AADObjectsTable 34 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 35 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 36 | | extend GroupMember = parse_json(PrincipalNames_s) 37 | | project-away PrincipalNames_s 38 | ) 39 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 40 | let DirectAssignments = RBACAssignmentsTable 41 | | where Model_s == 'AzureRM' 42 | | join kind=inner ( 43 | ServicePrincipals 44 | ) on $left.PrincipalId_g == $right.SPNId 45 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', KeyType, EndDate, Model_s, TenantGuid_g; 46 | let GroupAssignments = RBACAssignmentsTable 47 | | where Model_s == 'AzureRM' 48 | | join kind=inner ( 49 | GroupMemberships 50 | | join kind=inner ( 51 | ServicePrincipals 52 | ) on $left.GroupMember == $right.SPNId 53 | ) on $left.PrincipalId_g == $right.GroupId 54 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), KeyType, EndDate, Model_s, TenantGuid_g; 55 | GroupAssignments 56 | | union DirectAssignments 57 | | distinct DisplayName_s, RoleDefinition_s, Model_s, Scope_s, Assignment, KeyType, EndDate, PrincipalNames_s, TenantGuid_g 58 | | where EndDate > now() 59 | | order by DisplayName_s asc -------------------------------------------------------------------------------- /azuredeploy.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | param rgName string 3 | param readerRoleAssignmentGuid string = guid(subscription().subscriptionId, rgName) 4 | param contributorRoleAssignmentGuid string = guid(rgName) 5 | param projectLocation string 6 | 7 | @description('The base URI where artifacts required by this template are located') 8 | param templateLocation string 9 | 10 | param storageAccountName string 11 | param automationAccountName string 12 | param sqlServerName string 13 | param sqlDatabaseName string = 'azureoptimization' 14 | param logAnalyticsReuse bool 15 | param logAnalyticsWorkspaceName string 16 | param logAnalyticsWorkspaceRG string 17 | param logAnalyticsRetentionDays int = 120 18 | param sqlBackupRetentionDays int = 7 19 | param sqlAdminLogin string 20 | 21 | @secure() 22 | param sqlAdminPassword string 23 | param cloudEnvironment string = 'AzureCloud' 24 | param authenticationOption string = 'ManagedIdentity' 25 | 26 | @description('Base time for all automation runbook schedules.') 27 | param baseTime string = utcNow('u') 28 | param resourceTags object 29 | 30 | param roleReader string = '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7' 31 | 32 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 33 | name: rgName 34 | location: projectLocation 35 | tags: resourceTags 36 | dependsOn: [] 37 | } 38 | 39 | module resourcesDeployment './azuredeploy-nested.bicep' = { 40 | name: 'resourcesDeployment' 41 | scope: resourceGroup(rgName) 42 | params: { 43 | projectLocation: projectLocation 44 | templateLocation: templateLocation 45 | storageAccountName: storageAccountName 46 | automationAccountName: automationAccountName 47 | sqlServerName: sqlServerName 48 | sqlDatabaseName: sqlDatabaseName 49 | logAnalyticsReuse: logAnalyticsReuse 50 | logAnalyticsWorkspaceName: logAnalyticsWorkspaceName 51 | logAnalyticsWorkspaceRG: logAnalyticsWorkspaceRG 52 | logAnalyticsRetentionDays: logAnalyticsRetentionDays 53 | sqlBackupRetentionDays: sqlBackupRetentionDays 54 | sqlAdminLogin: sqlAdminLogin 55 | sqlAdminPassword: sqlAdminPassword 56 | cloudEnvironment: cloudEnvironment 57 | authenticationOption: authenticationOption 58 | baseTime: baseTime 59 | contributorRoleAssignmentGuid: contributorRoleAssignmentGuid 60 | resourceTags: resourceTags 61 | } 62 | dependsOn: [ 63 | rg 64 | ] 65 | } 66 | 67 | resource readerRoleAssignmentGuid_resource 'Microsoft.Authorization/roleAssignments@2018-09-01-preview' = { 68 | name: readerRoleAssignmentGuid 69 | properties: { 70 | roleDefinitionId: roleReader 71 | principalId: resourcesDeployment.outputs.automationPrincipalId 72 | principalType: 'ServicePrincipal' 73 | } 74 | } 75 | 76 | output automationPrincipalId string = resourcesDeployment.outputs.automationPrincipalId 77 | -------------------------------------------------------------------------------- /queries/rbac-spns-roles-arm-privileged.kql: -------------------------------------------------------------------------------- 1 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 2 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 3 | let PrivilegedRoles = dynamic(['Owner','Contributor','Global Administrator', 'Privileged Role Administrator', 'User Access Administrator','Exchange Administrator']); 4 | let AppsAndKeys = materialize (AADObjectsTable 5 | | where ObjectType_s in ('Application','ServicePrincipal') 6 | | where Keys_s startswith '[' 7 | | extend Keys = parse_json(Keys_s) 8 | | project-away Keys_s 9 | | mv-expand Keys 10 | | evaluate bag_unpack(Keys) 11 | | union ( 12 | AADObjectsTable 13 | | where ObjectType_s in ('Application','ServicePrincipal') 14 | | where isnotempty(Keys_s) and Keys_s !startswith '[' 15 | | extend Keys = parse_json(Keys_s) 16 | | project-away Keys_s 17 | | evaluate bag_unpack(Keys) 18 | ) 19 | ); 20 | let ServicePrincipals = materialize(AADObjectsTable 21 | | where isnotempty(ObjectId_g) 22 | | where ObjectType_s == 'ServicePrincipal' 23 | | join kind=inner ( 24 | AppsAndKeys 25 | ) on ApplicationId_g 26 | | project SPNId = ObjectId_g, PrincipalNames_s, DisplayName_s, KeyType, EndDate); 27 | let GroupMemberships = AADObjectsTable 28 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 29 | | where PrincipalNames_s startswith '[' 30 | | extend GroupMember = parse_json(PrincipalNames_s) 31 | | project-away PrincipalNames_s 32 | | mv-expand GroupMember 33 | | union ( 34 | AADObjectsTable 35 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 36 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 37 | | extend GroupMember = parse_json(PrincipalNames_s) 38 | | project-away PrincipalNames_s 39 | ) 40 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 41 | let DirectAssignments = RBACAssignmentsTable 42 | | where Model_s == 'AzureRM' 43 | | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' 44 | | join kind=inner ( 45 | ServicePrincipals 46 | ) on $left.PrincipalId_g == $right.SPNId 47 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', KeyType, EndDate, Model_s, TenantGuid_g; 48 | let GroupAssignments = RBACAssignmentsTable 49 | | where Model_s == 'AzureRM' 50 | | where RoleDefinition_s in (PrivilegedRoles) and Scope_s !has 'resourcegroups' 51 | | join kind=inner ( 52 | GroupMemberships 53 | | join kind=inner ( 54 | ServicePrincipals 55 | ) on $left.GroupMember == $right.SPNId 56 | ) on $left.PrincipalId_g == $right.GroupId 57 | | project PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), KeyType, EndDate, Model_s, TenantGuid_g; 58 | GroupAssignments 59 | | union DirectAssignments 60 | | distinct DisplayName_s, RoleDefinition_s, Model_s, Scope_s, Assignment, KeyType, EndDate, PrincipalNames_s, TenantGuid_g 61 | | where EndDate > now() 62 | | order by DisplayName_s asc -------------------------------------------------------------------------------- /model/recommendations-table.sql: -------------------------------------------------------------------------------- 1 | SET ANSI_NULLS ON 2 | SET QUOTED_IDENTIFIER ON 3 | IF NOT EXISTS (SELECT * FROM sysobjects WHERE id = object_id(N'[dbo].[Recommendations]') AND OBJECTPROPERTY(id, N'IsUserTable') = 1) 4 | BEGIN 5 | CREATE TABLE [dbo].[Recommendations]( 6 | [RecommendationId] [uniqueidentifier] NOT NULL DEFAULT NEWID(), 7 | [GeneratedDate] [datetime] NOT NULL, 8 | [Cloud] [varchar](20) NOT NULL, 9 | [Category] [varchar](50) NOT NULL, 10 | [ImpactedArea] [varchar](300) NOT NULL, 11 | [Impact] [varchar](20) NOT NULL, 12 | [RecommendationType] [varchar](50) NOT NULL, 13 | [RecommendationSubType] [varchar](50) NOT NULL, 14 | [RecommendationSubTypeId] [uniqueidentifier] NOT NULL, 15 | [RecommendationDescription] [nvarchar](1000) NULL, 16 | [RecommendationAction] [nvarchar](1000) NULL, 17 | [InstanceId] [varchar](1000) NULL, 18 | [InstanceName] [varchar](500) NULL, 19 | [AdditionalInfo] [nvarchar](max) NULL, 20 | [ResourceGroup] [varchar](200) NULL, 21 | [SubscriptionGuid] [varchar](50) NULL, 22 | [SubscriptionName] [varchar](250) NULL, 23 | [TenantGuid] [varchar](50) NULL, 24 | [FitScore] [real] NOT NULL, 25 | [Tags] [nvarchar](max) NULL, 26 | [DetailsUrl] [nvarchar](max) NULL 27 | ) 28 | 29 | ALTER TABLE [dbo].[Recommendations] ADD PRIMARY KEY CLUSTERED 30 | ( 31 | [RecommendationId] ASC 32 | )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF) ON [PRIMARY] 33 | 34 | CREATE INDEX IXC_Recommendations_SubTypeId ON [dbo].[Recommendations](RecommendationSubTypeId) 35 | 36 | CREATE INDEX IXC_Recommendations_GeneratedDate ON [dbo].[Recommendations](GeneratedDate) 37 | END 38 | ELSE 39 | BEGIN 40 | ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [RecommendationAction] VARCHAR (1000) NULL 41 | ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [InstanceId] VARCHAR (1000) NULL 42 | ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [InstanceName] VARCHAR (500) NULL 43 | ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [ResourceGroup] VARCHAR (200) NULL 44 | ALTER TABLE [dbo].[Recommendations] ALTER COLUMN [ImpactedArea] VARCHAR (300) NULL 45 | IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Recommendations]') AND name = 'FitScore') 46 | BEGIN 47 | EXEC sp_rename '[dbo].[Recommendations].ConfidenceScore', 'FitScore', 'COLUMN' 48 | END 49 | IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Recommendations]') AND name = 'SubscriptionName') 50 | BEGIN 51 | ALTER TABLE [dbo].[Recommendations] ADD [SubscriptionName] VARCHAR (250) NULL 52 | END 53 | IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Recommendations]') AND name = 'TenantGuid') 54 | BEGIN 55 | ALTER TABLE [dbo].[Recommendations] ADD [TenantGuid] VARCHAR (50) NULL 56 | END 57 | IF NOT EXISTS (SELECT * from sysindexes WHERE id=object_id('Recommendations') and name='IXC_Recommendations_SubTypeId') 58 | BEGIN 59 | CREATE INDEX IXC_Recommendations_SubTypeId ON [dbo].[Recommendations](RecommendationSubTypeId) 60 | END 61 | IF NOT EXISTS (SELECT * from sysindexes WHERE id=object_id('Recommendations') and name='IXC_Recommendations_GeneratedDate') 62 | BEGIN 63 | CREATE INDEX IXC_Recommendations_GeneratedDate ON [dbo].[Recommendations](GeneratedDate) 64 | END 65 | END -------------------------------------------------------------------------------- /queries/rbac-spns-keys-expiring.kql: -------------------------------------------------------------------------------- 1 | let expiryInterval = 30d; 2 | let AADObjectsTable = materialize(AzureOptimizationAADObjectsV1_CL | where TimeGenerated > ago(1d)); 3 | let RBACAssignmentsTable = materialize(AzureOptimizationRBACAssignmentsV1_CL | where TimeGenerated > ago(1d)); 4 | let AppsAndKeys = materialize (AADObjectsTable 5 | | where ObjectType_s in ('Application','ServicePrincipal') 6 | | where ObjectSubType_s != 'ManagedIdentity' 7 | | where Keys_s startswith '[' 8 | | extend Keys = parse_json(Keys_s) 9 | | project-away Keys_s 10 | | mv-expand Keys 11 | | evaluate bag_unpack(Keys) 12 | | union ( 13 | AADObjectsTable 14 | | where ObjectType_s in ('Application','ServicePrincipal') 15 | | where ObjectSubType_s != 'ManagedIdentity' 16 | | where isnotempty(Keys_s) and Keys_s !startswith '[' 17 | | extend Keys = parse_json(Keys_s) 18 | | project-away Keys_s 19 | | evaluate bag_unpack(Keys) 20 | ) 21 | ); 22 | let ExpirationInRisk = AppsAndKeys 23 | | where EndDate < now()+expiryInterval and EndDate > now() 24 | | project ApplicationId_g, KeyId, RiskDate = EndDate; 25 | let NotInRisk = AppsAndKeys 26 | | where EndDate > now()+expiryInterval 27 | | project ApplicationId_g, KeyId, ComfortDate = EndDate; 28 | let ApplicationsInRisk = ExpirationInRisk 29 | | join kind=leftouter ( NotInRisk ) on ApplicationId_g 30 | | where isempty(ComfortDate) 31 | | summarize ExpiresOn = max(RiskDate) by ApplicationId_g; 32 | let ServicePrincipals = materialize(AADObjectsTable 33 | | where isnotempty(ObjectId_g) 34 | | where ObjectType_s == 'ServicePrincipal' 35 | | project SPNId = ObjectId_g, ApplicationId_g, PrincipalNames_s, DisplayName_s); 36 | let GroupMemberships = AADObjectsTable 37 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 38 | | where PrincipalNames_s startswith '[' 39 | | extend GroupMember = parse_json(PrincipalNames_s) 40 | | project-away PrincipalNames_s 41 | | mv-expand GroupMember 42 | | union ( 43 | AADObjectsTable 44 | | where ObjectType_s == 'Group' and SecurityEnabled_s == 'True' 45 | | where isnotempty(PrincipalNames_s) and PrincipalNames_s !startswith '[' 46 | | extend GroupMember = parse_json(PrincipalNames_s) 47 | | project-away PrincipalNames_s 48 | ) 49 | | project GroupId = ObjectId_g, GroupName = DisplayName_s, GroupMember = tostring(GroupMember), TenantGuid_g, Cloud_s; 50 | let DirectAssignments = RBACAssignmentsTable 51 | | join kind=inner ( 52 | ServicePrincipals 53 | ) on $left.PrincipalId_g == $right.SPNId 54 | | project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = 'Direct', Model_s, TenantGuid_g; 55 | let GroupAssignments = RBACAssignmentsTable 56 | | join kind=inner ( 57 | GroupMemberships 58 | | join kind=inner ( 59 | ServicePrincipals 60 | ) on $left.GroupMember == $right.SPNId 61 | ) on $left.PrincipalId_g == $right.GroupId 62 | | project ApplicationId_g, PrincipalNames_s, DisplayName_s, RoleDefinition_s, Scope_s, Assignment = strcat('Group>',GroupName), Model_s, TenantGuid_g; 63 | AppsAndKeys 64 | | join kind=inner (ApplicationsInRisk) on ApplicationId_g 65 | | summarize ExpiresOn = max(EndDate) by ApplicationId_g, DisplayName_s, Cloud_s, KeyType, TenantGuid_g 66 | | join kind=inner ( 67 | GroupAssignments 68 | | union DirectAssignments 69 | ) on ApplicationId_g 70 | | distinct DisplayName_s, ExpiresOn, KeyType, RoleDefinition_s, Scope_s, Model_s, Cloud_s, TenantGuid_g 71 | | order by ExpiresOn asc -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ReservationsPriceToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $Filter = "serviceName eq 'Virtual Machines' and priceType eq 'Reservation'" # e.g., serviceName eq 'Virtual Machines' and priceType eq 'Reservation' and armRegionName eq 'northeurope' 4 | ) 5 | 6 | $ErrorActionPreference = "Stop" 7 | 8 | function Authenticate-AzureWithOption { 9 | param ( 10 | [string] $authOption = "ManagedIdentity", 11 | [string] $cloudEnv = "AzureCloud", 12 | [string] $clientID 13 | ) 14 | 15 | switch ($authOption) { 16 | "UserAssignedManagedIdentity" { 17 | Connect-AzAccount -Identity -EnvironmentName $cloudEnv -AccountId $clientID 18 | break 19 | } 20 | Default { #ManagedIdentity 21 | Connect-AzAccount -Identity -EnvironmentName $cloudEnv 22 | break 23 | } 24 | } 25 | } 26 | 27 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 28 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 29 | { 30 | $cloudEnvironment = "AzureCloud" 31 | } 32 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 33 | if ([string]::IsNullOrEmpty($authenticationOption)) 34 | { 35 | $authenticationOption = "ManagedIdentity" 36 | } 37 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 38 | { 39 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 40 | } 41 | 42 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 43 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 44 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 45 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 46 | if (-not($storageAccountSinkEnv)) 47 | { 48 | $storageAccountSinkEnv = $cloudEnvironment 49 | } 50 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 51 | $storageAccountSinkKey = $null 52 | if ($storageAccountSinkKeyCred) 53 | { 54 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 55 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 56 | } 57 | 58 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ReservationsPriceContainer" -ErrorAction SilentlyContinue 59 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 60 | { 61 | $storageAccountSinkContainer = "reservationspriceexports" 62 | } 63 | 64 | $filterVar = Get-AutomationVariable -Name "AzureOptimization_RetailPricesFilter" -ErrorAction SilentlyContinue 65 | $currencyCode = Get-AutomationVariable -Name "AzureOptimization_RetailPricesCurrencyCode" 66 | 67 | "Logging in to Azure with $authenticationOption..." 68 | 69 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 70 | { 71 | Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment -clientID $uamiClientID 72 | } 73 | else 74 | { 75 | Authenticate-AzureWithOption -authOption $authenticationOption -cloudEnv $cloudEnvironment 76 | } 77 | 78 | if (-not($storageAccountSinkKey)) 79 | { 80 | Write-Output "Getting Storage Account context with login" 81 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 82 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 83 | } 84 | else 85 | { 86 | Write-Output "Getting Storage Account context with key" 87 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 88 | } 89 | 90 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 91 | { 92 | "Logging in to Azure with $externalCredentialName external credential..." 93 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 94 | $cloudEnvironment = $externalCloudEnvironment 95 | } 96 | 97 | if (-not([string]::IsNullOrEmpty($filterVar))) 98 | { 99 | $Filter = $filterVar 100 | } 101 | 102 | Write-Output "Starting retails prices export process with $currencyCode currency code and filter: $Filter ..." 103 | 104 | $RetailPricesApiPath = "https://prices.azure.com/api/retail/prices?currencyCode='$currencyCode'&`$filter=$Filter" 105 | 106 | $prices = @() 107 | 108 | do 109 | { 110 | $Response = Invoke-RestMethod -Method Get -Uri $RetailPricesApiPath 111 | if ($Response.Items.Count -gt 0) 112 | { 113 | $prices += $Response.Items 114 | } 115 | $RetailPricesApiPath = $Response.NextPageLink 116 | } while ($Response.NextPageLink) 117 | 118 | $datetime = (get-date).ToUniversalTime() 119 | $timestamp = $datetime.ToString("yyyyMMdd") 120 | 121 | $fileFriendlyFilter = $Filter.Replace(" ","").Replace("'","") 122 | $csvExportPath = "reservationsprice-$timestamp-$fileFriendlyFilter.csv" 123 | 124 | $ci = [CultureInfo]::new([System.Threading.Thread]::CurrentThread.CurrentCulture.Name) 125 | if ($ci.NumberFormat.NumberDecimalSeparator -ne '.') 126 | { 127 | Write-Output "Current culture ($($ci.Name)) does not use . as decimal separator" 128 | $ci.NumberFormat.NumberDecimalSeparator = '.' 129 | [System.Threading.Thread]::CurrentThread.CurrentCulture = $ci 130 | } 131 | 132 | $prices | Export-Csv -NoTypeInformation -Path $csvExportPath 133 | 134 | Write-Output "Reservations price CSV exported to $csvExportPath successfully." 135 | 136 | $csvBlobName = $csvExportPath 137 | $csvProperties = @{"ContentType" = "text/csv"}; 138 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 139 | 140 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 141 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 142 | 143 | Remove-Item -Path $csvExportPath -Force 144 | 145 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 146 | Write-Output "[$now] Removed $csvExportPath from local disk..." 147 | -------------------------------------------------------------------------------- /docs/configuring-workspaces.md: -------------------------------------------------------------------------------- 1 | # Configuring Log Analytics workspaces 2 | 3 | ## Validating/configuring performance counters collection 4 | 5 | If you want to fully leverage the VM right-size augmented recommendation, you need to have your VMs sending logs to a Log Analytics workspace (it should normally be the one you chose at AOE installation time, but it can be a different one) and you need them to send specific performance counters. The list of required counters is defined [here](../perfcounters.json). The AOE provides a couple of tools that help you validate and fix the configured Log Analytics performance counters, depending on the type of agent you are using to collect logs from your machines. 6 | 7 | ### Azure Monitor Agent (preferred approach) 8 | 9 | With the help of the [Setup-DataCollectionRules.ps1](./Setup-DataCollectionRules.ps1) script, you can create a couple of Data Collection Rules (DCR) - one per OS type - that you configure to stream performance counters to the Log Analytics workspace of your choice. After creating the DCRs with the script below, you just have to manually or automatically (e.g., with Azure Policy) associate your VMs to the respective DCRs. 10 | 11 | #### Requirements 12 | 13 | ```powershell 14 | Install-Module -Name Az.Accounts 15 | Install-Module -Name Az.Resources 16 | Install-Module -Name Az.OperationalInsights 17 | ``` 18 | 19 | #### Usage 20 | 21 | ```powershell 22 | ./Setup-DataCollectionRules.ps1 -DestinationWorkspaceResourceId [-AzureEnvironment ] [-IntervalSeconds ] [-ResourceTags ] 23 | 24 | # Example 1 - create Linux and Windows DCRs with the default options 25 | ./Setup-DataCollectionRules.ps1 -DestinationWorkspaceResourceId "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myResourceGroup/providers/Microsoft.OperationalInsights/workspaces/myWorkspace" 26 | 27 | # Example 2 - create DCRs using a custom counter collection frequency and assigning specific tags 28 | ./Setup-DataCollectionRules.ps1 -DestinationWorkspaceResourceId "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myResourceGroup/providers/Microsoft.OperationalInsights/workspaces/myWorkspace" -IntervalSeconds 30 -ResourceTags @{"tagName"="tagValue";"otherTagName"="otherTagValue"} 29 | ``` 30 | 31 | ### Log Analytics agent (legacy Microsoft Monitoring Agent) 32 | 33 | With the help of the [Setup-LogAnalyticsWorkspaces.ps1](./Setup-LogAnalyticsWorkspaces.ps1) script, you can validate and fix the configured Log Analytics performance counters on the workspaces of your choice. In its simplest form of usage, it looks at all the Log Analytics workspaces you have access to and, for each workspace with Azure VMs onboarded, it validates performance counters configuration and tells you which counters are missing. But you can target a specific workspace and, if required, automatically fix the missing counters. See usage details below. 34 | 35 | #### Requirements 36 | 37 | ```powershell 38 | Install-Module -Name Az.Accounts 39 | Install-Module -Name Az.ResourceGraph 40 | Install-Module -Name Az.OperationalInsights 41 | ``` 42 | 43 | #### Usage 44 | 45 | ```powershell 46 | ./Setup-LogAnalyticsWorkspaces.ps1 [-AzureEnvironment ] [-WorkspaceIds ] [-IntervalSeconds ] [-AutoFix] 47 | 48 | # Example 1 - just check all the workspaces configuration 49 | ./Setup-LogAnalyticsWorkspaces.ps1 50 | 51 | # Example 2 - fix all workspaces configuration (using default counter collection frequency) 52 | ./Setup-LogAnalyticsWorkspaces.ps1 -AutoFix 53 | 54 | # Example 3 - fix specific workspaces configuration, using a custom counter collection frequency 55 | ./Setup-LogAnalyticsWorkspaces.ps1 -AutoFix -WorkspaceIds "d69e840a-2890-4451-b63c-bcfc5580b90f","961550b2-2c4a-481a-9559-ddf53de4b455" -IntervalSeconds 30 56 | ``` 57 | 58 | ## Estimating the cost of onboarding additional VMs or adding missing performance metrics 59 | 60 | Each performance counter entry in the `Perf` table has different sizings, depending on the [7 required counters](../perfcounters.json) per OS type. The following table enumerates the size (in bytes) per performance counter entry. 61 | 62 | OS Type | Object | Counter | Size | Collections per interval/VM 63 | --- | --- | --- | ---: | --- | 64 | Windows | Processor | % Processor Time | 200 | 1 + vCPUs count 65 | Windows | Memory | Available MBytes | 220 | 1 66 | Windows | LogicalDisk | Disk Read Bytes/sec | 250 | 3 + data disks count 67 | Windows | LogicalDisk | Disk Write Bytes/sec | 250 | 3 + data disks count 68 | Windows | LogicalDisk | Disk Reads/sec | 250 | 3 + data disks count 69 | Windows | LogicalDisk | Disk Writes/sec | 250 | 3 + data disks count 70 | Windows | Network Adapter | Bytes Total/sec | 290 | network adapters count 71 | Linux | Processor | % Processor Time | 200 72 | Linux | Memory | % Used Memory | 200 73 | Linux | Logical Disk | Disk Read Bytes/sec | 250 | 3 + data disks count 74 | Linux | Logical Disk | Disk Write Bytes/sec | 250 | 3 + data disks count 75 | Linux | Logical Disk | Disk Reads/sec | 250 | 3 + data disks count 76 | Linux | Logical Disk | Disk Writes/sec | 250 | 3 + data disks count 77 | Linux | Network | Total Bytes | 200 | network adapters count 78 | 79 | In summary, a Windows VM generates, in average, 245 bytes per performance counter entry, while a Linux consumes a bit less, 230 bytes per entry. However, depending on the number of CPU cores, data disks or network adapters, a VM will generate more or less Log Analytics entries. For example, a Windows VM with 4 vCPUs, 1 data disk and 5 network adapters will generate 5 * 200 + 220 + 4 * 250 + 4 * 250 + 4 * 250 + 4 * 250 + 5 * 290 = 6670 bytes (6.5 KB) per collection interval. If you set your Performance Counters interval to 60 seconds, then you'll have 60 * 24 * 30 * 6.5 = 280800 KB (274 MB) of ingestion data per month, which means less than 0.70 EUR/month at the Log Analytics ingestion retail price (Pay As You Go). 80 | 81 | ## Using multiple Log Analytics workspaces for VM performance metrics 82 | 83 | If you have VMs onboarded to multiple Log Analytics workspaces and you want them to be fully included in the VM right-size recommendations report, you can add those workspaces to the solution just by adding a new variable to the AOE Azure Automation account. In the Automation Account _Shared Resources - Variables_ menu option, click on the _Add a variable button_ and enter `AzureOptimization_RightSizeAdditionalPerfWorkspaces` as the variable name and fill in the comma-separated list of workspace IDs (see example below). Finally, click on _Create_. 84 | 85 | ![Adding an Automation Account variable with a list of additional workspace IDs for the VM right-size recommendations](./loganalytics-additionalperfworkspaces.jpg "Additional workspace IDs variable creation") -------------------------------------------------------------------------------- /Setup-LogAnalyticsWorkspaces.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [String] $AzureEnvironment = "AzureCloud", 4 | 5 | [Parameter(Mandatory = $false)] 6 | [String[]] $WorkspaceIds, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [switch] $AutoFix, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [int] $IntervalSeconds = 60 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $ctx = Get-AzContext 18 | if (-not($ctx)) { 19 | Connect-AzAccount -Environment $AzureEnvironment 20 | $ctx = Get-AzContext 21 | } 22 | else { 23 | if ($ctx.Environment.Name -ne $AzureEnvironment) { 24 | Disconnect-AzAccount -ContextName $ctx.Name 25 | Connect-AzAccount -Environment $AzureEnvironment 26 | $ctx = Get-AzContext 27 | } 28 | } 29 | 30 | $wsIds = foreach ($workspaceId in $WorkspaceIds) 31 | { 32 | "'$workspaceId'" 33 | } 34 | if ($wsIds) 35 | { 36 | $wsIds = $wsIds -join "," 37 | $whereWsIds = " and properties.customerId in ($wsIds)" 38 | } 39 | 40 | $perfCounters = Get-Content -Path ".\perfcounters.json" | ConvertFrom-Json 41 | 42 | $ARGPageSize = 1000 43 | 44 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 45 | 46 | $argQuery = "resources | where type =~ 'microsoft.operationalinsights/workspaces'$whereWsIds | order by id" 47 | 48 | $workspaces = (Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions).data 49 | 50 | Write-Output "Found $($workspaces.Count) workspaces." 51 | 52 | $laQuery = "Heartbeat | where TimeGenerated > ago(1d) and ComputerEnvironment == 'Azure' | distinct Computer | summarize AzureComputersCount = count()" 53 | 54 | foreach ($workspace in $workspaces) { 55 | $laQueryResults = $null 56 | $results = $null 57 | $laQueryResults = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspace.properties.customerId -Query $laQuery -Timespan (New-TimeSpan -Days 1) -ErrorAction Continue 58 | if ($laQueryResults) 59 | { 60 | $results = [System.Linq.Enumerable]::ToArray($laQueryResults.Results) 61 | Write-Output "$($workspace.name) ($($workspace.properties.customerId)): $($results.AzureComputersCount) Azure computers connected." 62 | } 63 | else 64 | { 65 | Write-Output "$($workspace.name) ($($workspace.properties.customerId)): could not validate connected computers." 66 | } 67 | if ($results.AzureComputersCount -gt 0) 68 | { 69 | if ($ctx.Subscription.SubscriptionId -ne $workspace.subscriptionId) 70 | { 71 | $ctx = Set-AzContext -SubscriptionId $workspace.subscriptionId 72 | } 73 | $dsWindows = Get-AzOperationalInsightsDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name -Kind WindowsPerformanceCounter 74 | foreach ($perfCounter in ($perfCounters | Where-Object {$_.osType -eq "Windows"})) { 75 | if (-not($dsWindows | Where-Object { $_.Properties.ObjectName -eq $perfCounter.objectName -and $_.Properties.InstanceName -eq $perfCounter.instance ` 76 | -and $_.Properties.CounterName -eq $perfCounter.counterName})) 77 | { 78 | Write-Output "Missing $($perfCounter.objectName)($($perfCounter.instance))\$($perfCounter.counterName)" 79 | if ($AutoFix) 80 | { 81 | Write-Output "Fixing..." 82 | $dsName = "DataSource_WindowsPerformanceCounter_$(New-Guid)" 83 | New-AzOperationalInsightsWindowsPerformanceCounterDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name ` 84 | -Name $dsName -ObjectName $perfCounter.objectName -CounterName $perfCounter.counterName -InstanceName $perfCounter.instance ` 85 | -IntervalSeconds $IntervalSeconds -Force | Out-Null 86 | } 87 | } 88 | } 89 | 90 | $missingLinuxCounters = @() 91 | $dsLinux = Get-AzOperationalInsightsDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name -Kind LinuxPerformanceObject 92 | foreach ($perfCounter in ($perfCounters | Where-Object {$_.osType -eq "Linux"})) { 93 | if (-not($dsLinux | Where-Object { $_.Properties.ObjectName -eq $perfCounter.objectName -and $_.Properties.InstanceName -eq $perfCounter.instance ` 94 | -and ($_.Properties.PerformanceCounters | Where-Object { $_.CounterName -eq $perfCounter.counterName }) })) 95 | { 96 | Write-Output "Missing $($perfCounter.objectName)($($perfCounter.instance))\$($perfCounter.counterName)" 97 | if ($AutoFix) 98 | { 99 | $missingLinuxCounters += $perfCounter 100 | } 101 | } 102 | } 103 | 104 | if ($AutoFix) 105 | { 106 | $fixedLinuxCounters = @() 107 | $existingLinuxObjects = ($dsLinux | Select-Object -ExpandProperty Properties | Select-Object -Property ObjectName).ObjectName 108 | foreach ($linuxObject in $existingLinuxObjects) { 109 | $missingObjectCounters = $missingLinuxCounters | Where-Object { $_.objectName -eq $linuxObject } 110 | $originalDataSource = $dsLinux | Where-Object { $_.Properties.ObjectName -eq $linuxObject } 111 | foreach ($perfCounter in $missingObjectCounters) { 112 | $fixedLinuxCounters += $perfCounter 113 | $newCounterName = New-Object -TypeName Microsoft.Azure.Commands.OperationalInsights.Models.PerformanceCounterIdentifier -Property @{CounterName = $perfCounter.counterName} 114 | $originalDataSource.Properties.PerformanceCounters.Add($newCounterName) 115 | } 116 | if ($missingObjectCounters) 117 | { 118 | Write-Output "Fixing $linuxObject object..." 119 | Set-AzOperationalInsightsDataSource -DataSource $originalDataSource | Out-Null 120 | } 121 | } 122 | $missingObjects = ($missingLinuxCounters | Select-Object -Property objectName -Unique).objectName 123 | $fixedObjects = ($fixedLinuxCounters | Select-Object -Property objectName -Unique).objectName 124 | $missingObjects = $missingObjects | Where-Object { -not($_ -in $fixedObjects) } 125 | foreach ($linuxObject in $missingObjects) { 126 | $missingObjectCounters = $missingLinuxCounters | Where-Object { $_.objectName -eq $linuxObject } 127 | $missingInstance = ($missingObjectCounters | Select-Object -Property instance -Unique -First 1).instance 128 | $missingCounterNames = ($missingObjectCounters).counterName 129 | 130 | Write-Output "Adding $linuxObject object..." 131 | New-AzOperationalInsightsLinuxPerformanceObjectDataSource -ResourceGroupName $workspace.resourceGroup -WorkspaceName $workspace.name ` 132 | -Name "DataSource_LinuxPerformanceObject_$(New-Guid)" -ObjectName $linuxObject -InstanceName $missingInstance -IntervalSeconds $IntervalSeconds ` 133 | -CounterNames $missingCounterNames -Force | Out-Null 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /model/loganalyticsingestcontrol-initialize.sql: -------------------------------------------------------------------------------- 1 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvmexports') 2 | BEGIN 3 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 4 | VALUES ('argvmexports', '1901-01-01T00:00:00Z', -1, 'VMsV1', 'ARGVirtualMachine') 5 | END 6 | 7 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argdiskexports') 8 | BEGIN 9 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 10 | VALUES ('argdiskexports', '1901-01-01T00:00:00Z', -1, 'DisksV1', 'ARGManagedDisk') 11 | END 12 | 13 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvhdexports') 14 | BEGIN 15 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 16 | VALUES ('argvhdexports', '1901-01-01T00:00:00Z', -1, 'VhdDisksV1', 'ARGUnmanagedDisk') 17 | END 18 | 19 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argavailsetexports') 20 | BEGIN 21 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 22 | VALUES ('argavailsetexports', '1901-01-01T00:00:00Z', -1, 'AvailSetsV1', 'ARGAvailabilitySet') 23 | END 24 | 25 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'advisorexports') 26 | BEGIN 27 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 28 | VALUES ('advisorexports', '1901-01-01T00:00:00Z', -1, 'AdvisorV1', 'AzureAdvisor') 29 | END 30 | 31 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'remediationlogs') 32 | BEGIN 33 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 34 | VALUES ('remediationlogs', '1901-01-01T00:00:00Z', -1, 'RemediationV1', 'RemediationLogs') 35 | END 36 | 37 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'consumptionexports') 38 | BEGIN 39 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 40 | VALUES ('consumptionexports', '1901-01-01T00:00:00Z', -1, 'ConsumptionV1', 'AzureConsumption') 41 | END 42 | 43 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'aadobjectsexports') 44 | BEGIN 45 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 46 | VALUES ('aadobjectsexports', '1901-01-01T00:00:00Z', -1, 'AADObjectsV1', 'AADObjects') 47 | END 48 | 49 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'arglbexports') 50 | BEGIN 51 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 52 | VALUES ('arglbexports', '1901-01-01T00:00:00Z', -1, 'LoadBalancersV1', 'ARGLoadBalancer') 53 | END 54 | 55 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argappgwexports') 56 | BEGIN 57 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 58 | VALUES ('argappgwexports', '1901-01-01T00:00:00Z', -1, 'AppGatewaysV1', 'ARGAppGateway') 59 | END 60 | 61 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argrescontainersexports') 62 | BEGIN 63 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 64 | VALUES ('argrescontainersexports', '1901-01-01T00:00:00Z', -1, 'ResourceContainersV1', 'ARGResourceContainers') 65 | END 66 | 67 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'rbacexports') 68 | BEGIN 69 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 70 | VALUES ('rbacexports', '1901-01-01T00:00:00Z', -1, 'RBACAssignmentsV1', 'RBACAssignments') 71 | END 72 | 73 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvnetexports') 74 | BEGIN 75 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 76 | VALUES ('argvnetexports', '1901-01-01T00:00:00Z', -1, 'VNetsV1', 'ARGVirtualNetwork') 77 | END 78 | 79 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argnicexports') 80 | BEGIN 81 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 82 | VALUES ('argnicexports', '1901-01-01T00:00:00Z', -1, 'NICsV1', 'ARGNetworkInterface') 83 | END 84 | 85 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argnsgexports') 86 | BEGIN 87 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 88 | VALUES ('argnsgexports', '1901-01-01T00:00:00Z', -1, 'NSGsV1', 'ARGNSGRule') 89 | END 90 | 91 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argpublicipexports') 92 | BEGIN 93 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 94 | VALUES ('argpublicipexports', '1901-01-01T00:00:00Z', -1, 'PublicIPsV1', 'ARGPublicIP') 95 | END 96 | 97 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argvmssexports') 98 | BEGIN 99 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 100 | VALUES ('argvmssexports', '1901-01-01T00:00:00Z', -1, 'VMSSV1', 'ARGVMSS') 101 | END 102 | 103 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argsqldbexports') 104 | BEGIN 105 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 106 | VALUES ('argsqldbexports', '1901-01-01T00:00:00Z', -1, 'SqlDbV1', 'ARGSqlDb') 107 | END 108 | 109 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'azmonitorexports') 110 | BEGIN 111 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 112 | VALUES ('azmonitorexports', '1901-01-01T00:00:00Z', -1, 'MonitorMetricsV1', 'MonitorMetrics') 113 | END 114 | 115 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'policystateexports') 116 | BEGIN 117 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 118 | VALUES ('policystateexports', '1901-01-01T00:00:00Z', -1, 'PolicyStatesV1', 'PolicyStates') 119 | END 120 | 121 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'recommendationsexports') 122 | BEGIN 123 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 124 | VALUES ('recommendationsexports', '2022-12-26T00:00:00Z', -1, 'RecommendationsV1', 'Recommendations') 125 | END 126 | 127 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'reservationsexports') 128 | BEGIN 129 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 130 | VALUES ('reservationsexports', '1901-01-01T00:00:00Z', -1, 'ReservationsUsageV1', 'ReservationsUsage') 131 | END 132 | 133 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'argappserviceplanexports') 134 | BEGIN 135 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 136 | VALUES ('argappserviceplanexports', '1901-01-01T00:00:00Z', -1, 'AppServicePlansV1', 'AppServicePlans') 137 | END 138 | 139 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'pricesheetexports') 140 | BEGIN 141 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 142 | VALUES ('pricesheetexports', '1901-01-01T00:00:00Z', -1, 'PricesheetV1', 'Pricesheet') 143 | END 144 | 145 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'reservationspriceexports') 146 | BEGIN 147 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 148 | VALUES ('reservationspriceexports', '1901-01-01T00:00:00Z', -1, 'ReservationsPriceV1', 'ReservationsPrice') 149 | END 150 | 151 | IF NOT EXISTS (SELECT * FROM [dbo].[LogAnalyticsIngestControl] WHERE StorageContainerName = 'savingsplansexports') 152 | BEGIN 153 | INSERT INTO [dbo].[LogAnalyticsIngestControl] 154 | VALUES ('savingsplansexports', '1901-01-01T00:00:00Z', -1, 'SavingsPlansUsageV1', 'SavingsPlansUsage') 155 | END 156 | -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGAvailabilitySetPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 23 | if ([string]::IsNullOrEmpty($authenticationOption)) 24 | { 25 | $authenticationOption = "ManagedIdentity" 26 | } 27 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 28 | { 29 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 30 | } 31 | 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 36 | if (-not($storageAccountSinkEnv)) 37 | { 38 | $storageAccountSinkEnv = $cloudEnvironment 39 | } 40 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 41 | $storageAccountSinkKey = $null 42 | if ($storageAccountSinkKeyCred) 43 | { 44 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 45 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 46 | } 47 | 48 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGAvailabilitySetContainer" -ErrorAction SilentlyContinue 49 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 50 | { 51 | $storageAccountSinkContainer = "argavailsetexports" 52 | } 53 | 54 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 55 | { 56 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 57 | } 58 | 59 | $ARGPageSize = 1000 60 | 61 | "Logging in to Azure with $authenticationOption..." 62 | 63 | switch ($authenticationOption) { 64 | "UserAssignedManagedIdentity" { 65 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 66 | break 67 | } 68 | Default { #ManagedIdentity 69 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 70 | break 71 | } 72 | } 73 | 74 | if (-not($storageAccountSinkKey)) 75 | { 76 | Write-Output "Getting Storage Account context with login" 77 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 78 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 79 | } 80 | else 81 | { 82 | Write-Output "Getting Storage Account context with key" 83 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 84 | } 85 | 86 | $cloudSuffix = "" 87 | 88 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 89 | { 90 | "Logging in to Azure with $externalCredentialName external credential..." 91 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 92 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 93 | $cloudEnvironment = $externalCloudEnvironment 94 | } 95 | 96 | $tenantId = (Get-AzContext).Tenant.Id 97 | 98 | $allAvSets = @() 99 | 100 | Write-Output "Getting subscriptions target $TargetSubscription" 101 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 102 | { 103 | $subscriptions = $TargetSubscription 104 | $subscriptionSuffix = $TargetSubscription 105 | } 106 | else 107 | { 108 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 109 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 110 | } 111 | 112 | $avSetsTotal = @() 113 | $resultsSoFar = 0 114 | 115 | Write-Output "Querying for Availability Set properties" 116 | 117 | $argQuery = @" 118 | resources 119 | | where type =~ 'Microsoft.Compute/availabilitySets' 120 | | project id, name, location, resourceGroup, subscriptionId, tenantId, skuName = tostring(sku.name), faultDomains = tostring(properties.platformFaultDomainCount), updateDomains = tostring(properties.platformUpdateDomainCount), vmCount = array_length(properties.virtualMachines), tags, zones 121 | | order by id asc 122 | "@ 123 | 124 | do 125 | { 126 | if ($resultsSoFar -eq 0) 127 | { 128 | $avSets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 129 | } 130 | else 131 | { 132 | $avSets = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 133 | } 134 | if ($avSets -and $avSets.GetType().Name -eq "PSResourceGraphResponse") 135 | { 136 | $avSets = $avSets.Data 137 | } 138 | $resultsCount = $avSets.Count 139 | $resultsSoFar += $resultsCount 140 | $avSetsTotal += $avSets 141 | 142 | } while ($resultsCount -eq $ARGPageSize) 143 | 144 | Write-Output "Found $($avSetsTotal.Count) Availability Set entries" 145 | 146 | <# 147 | Building CSV entries 148 | #> 149 | 150 | $datetime = (get-date).ToUniversalTime() 151 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 152 | $statusDate = $datetime.ToString("yyyy-MM-dd") 153 | 154 | foreach ($avSet in $avSetsTotal) 155 | { 156 | $logentry = New-Object PSObject -Property @{ 157 | Timestamp = $timestamp 158 | Cloud = $cloudEnvironment 159 | TenantGuid = $avSet.tenantId 160 | SubscriptionGuid = $avSet.subscriptionId 161 | ResourceGroupName = $avSet.resourceGroup.ToLower() 162 | InstanceName = $avSet.name.ToLower() 163 | InstanceId = $avSet.id.ToLower() 164 | SkuName = $avSet.skuName 165 | Location = $avSet.location 166 | FaultDomains = $avSet.faultDomains 167 | UpdateDomains = $avSet.updateDomains 168 | VmCount = $avSet.vmCount 169 | StatusDate = $statusDate 170 | Tags = $avSet.tags 171 | Zones = $avSet.zones 172 | } 173 | 174 | $allAvSets += $logentry 175 | } 176 | 177 | <# 178 | Actually exporting CSV to Azure Storage 179 | #> 180 | 181 | $today = $datetime.ToString("yyyyMMdd") 182 | $csvExportPath = "$today-availsets-$subscriptionSuffix.csv" 183 | 184 | $allAvSets | Export-Csv -Path $csvExportPath -NoTypeInformation 185 | 186 | $csvBlobName = $csvExportPath 187 | 188 | $csvProperties = @{"ContentType" = "text/csv"}; 189 | 190 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 191 | 192 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 193 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 194 | 195 | Remove-Item -Path $csvExportPath -Force 196 | 197 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 198 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /views/powerbi-query.m: -------------------------------------------------------------------------------- 1 | let 2 | Source = Sql.Database("aoedevgithub-sql.database.windows.net", "azureoptimization", [Query="EXEC GetRecommendations", CommandTimeout=#duration(0, 2, 0, 0)]), 3 | #"Parsed JSON Tags" = Table.TransformColumns(Source,{{"Tags", Json.Document}}), 4 | #"Expanded Tags" = Table.ExpandRecordColumn(#"Parsed JSON Tags", "Tags", {"environment", "costcenter"}, {"Tags.Environment", "Tags.CostCenter"}), 5 | #"Trimmed Text" = Table.TransformColumns(#"Expanded Tags",{{"Tags.Environment", Text.Trim, type text}, {"Tags.CostCenter", Text.Trim, type text}}), 6 | #"Duplicated AdditionalInfo" = Table.DuplicateColumn(#"Trimmed Text","AdditionalInfo", "AddInfoJSON"), 7 | #"Parsed JSON AdditionalInfo" = Table.TransformColumns(#"Duplicated AdditionalInfo",{{"AdditionalInfo", Json.Document}}), 8 | #"Expanded AdditionalInfo" = Table.ExpandRecordColumn(#"Parsed JSON AdditionalInfo", "AdditionalInfo", {"BelowNetworkThreshold", "SupportsDataDisksCount", "MetricIOPS", "SupportsIOPS", "BelowMemoryThreshold", "currentSku", "targetSku", "SupportsNICCount", "DataDiskCount", "MetricMemoryPercentage", "MetricNetworkMbps", "MetricMiBps", "SupportsMiBps", "BelowCPUThreshold", "NicCount", "MetricCPUPercentage", "annualSavingsAmount", "savingsCurrency", "savingsAmount", "CostsAmount", "reservationType", "vmSize", "DiskType", "DiskSizeGB", "MaxMemoryP95", "MaxCpuP95", "MaxTotalNetworkP95", "DeploymentModel", "scope", "region", "qty", "displaySKU", "term"}, {"AddInfo.BelowNetworkThreshold", "AddInfo.SupportsDataDisksCount", "AddInfo.MetricIOPS", "AddInfo.SupportsIOPS", "AddInfo.BelowMemoryThreshold", "AddInfo.currentSku", "AddInfo.targetSku", "AddInfo.SupportsNICCount", "AddInfo.DataDiskCount", "AddInfo.MetricMemoryPercentage", "AddInfo.MetricNetworkMbps", "AddInfo.MetricMiBps", "AddInfo.SupportsMiBps", "AddInfo.BelowCPUThreshold", "AddInfo.NicCount", "AddInfo.MetricCPUPercentage", "AddInfo.annualSavingsAmount", "AddInfo.savingsCurrency", "AddInfo.savingsAmount", "AddInfo.CostsAmount", "AddInfo.reservationType", "AddInfo.vmSize", "AddInfo.DiskType", "AddInfo.DiskSizeGB", "AddInfo.MaxMemoryP95", "AddInfo.MaxCpuP95", "AddInfo.MaxTotalNetworkP95", "AddInfo.DeploymentModel", "AddInfo.Scope", "AddInfo.Region", "AddInfo.Quantity", "AddInfo.ReservationSKU", "AddInfo.Term"}), 9 | #"Split Column by Delimiter" = Table.SplitColumn(#"Expanded AdditionalInfo", "AddInfo.BelowNetworkThreshold", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.BelowNetworkThreshold.1", "AddInfo.BelowNetworkThreshold.2"}), 10 | #"Changed Type" = Table.TransformColumnTypes(#"Split Column by Delimiter",{{"AddInfo.BelowNetworkThreshold.1", type text}, {"AddInfo.BelowNetworkThreshold.2", type text}}), 11 | #"Renamed Columns" = Table.RenameColumns(#"Changed Type",{{"AddInfo.BelowNetworkThreshold.2", "AddInfo.BelowNetworkThresholdDetails"}, {"AddInfo.BelowNetworkThreshold.1", "AddInfo.BelowNetworkThresholdResult"}}), 12 | #"Split Column by Delimiter1" = Table.SplitColumn(#"Renamed Columns", "AddInfo.SupportsDataDisksCount", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsDataDisksCount.1", "AddInfo.SupportsDataDisksCount.2"}), 13 | #"Changed Type1" = Table.TransformColumnTypes(#"Split Column by Delimiter1",{{"AddInfo.SupportsDataDisksCount.1", type logical}, {"AddInfo.SupportsDataDisksCount.2", type text}}), 14 | #"Renamed Columns1" = Table.RenameColumns(#"Changed Type1",{{"AddInfo.SupportsDataDisksCount.1", "AddInfo.SupportsDataDisksCountResult"}, {"AddInfo.SupportsDataDisksCount.2", "AddInfo.SupportsDataDisksCountDetails"}}), 15 | #"Split Column by Delimiter2" = Table.SplitColumn(#"Renamed Columns1", "AddInfo.SupportsIOPS", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsIOPS.1", "AddInfo.SupportsIOPS.2"}), 16 | #"Changed Type2" = Table.TransformColumnTypes(#"Split Column by Delimiter2",{{"AddInfo.SupportsIOPS.1", type text}, {"AddInfo.SupportsIOPS.2", type text}}), 17 | #"Renamed Columns2" = Table.RenameColumns(#"Changed Type2",{{"AddInfo.SupportsIOPS.1", "AddInfo.SupportsIOPSResult"}, {"AddInfo.SupportsIOPS.2", "AddInfo.SupportsIOPSDetails"}}), 18 | #"Split Column by Delimiter3" = Table.SplitColumn(#"Renamed Columns2", "AddInfo.BelowMemoryThreshold", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.BelowMemoryThreshold.1", "AddInfo.BelowMemoryThreshold.2"}), 19 | #"Changed Type3" = Table.TransformColumnTypes(#"Split Column by Delimiter3",{{"AddInfo.BelowMemoryThreshold.1", type text}, {"AddInfo.BelowMemoryThreshold.2", type text}}), 20 | #"Renamed Columns3" = Table.RenameColumns(#"Changed Type3",{{"AddInfo.BelowMemoryThreshold.1", "AddInfo.BelowMemoryThresholdResult"}, {"AddInfo.BelowMemoryThreshold.2", "AddInfo.BelowMemoryThresholdDetails"}}), 21 | #"Split Column by Delimiter4" = Table.SplitColumn(#"Renamed Columns3", "AddInfo.SupportsNICCount", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsNICCount.1", "AddInfo.SupportsNICCount.2"}), 22 | #"Changed Type4" = Table.TransformColumnTypes(#"Split Column by Delimiter4",{{"AddInfo.SupportsNICCount.1", type logical}, {"AddInfo.SupportsNICCount.2", type text}}), 23 | #"Renamed Columns4" = Table.RenameColumns(#"Changed Type4",{{"AddInfo.SupportsNICCount.1", "AddInfo.SupportsNICCountResult"}, {"AddInfo.SupportsNICCount.2", "AddInfo.SupportsNICCountDetails"}}), 24 | #"Split Column by Delimiter5" = Table.SplitColumn(#"Renamed Columns4", "AddInfo.SupportsMiBps", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.SupportsMiBps.1", "AddInfo.SupportsMiBps.2"}), 25 | #"Changed Type5" = Table.TransformColumnTypes(#"Split Column by Delimiter5",{{"AddInfo.SupportsMiBps.1", type text}, {"AddInfo.SupportsMiBps.2", type text}}), 26 | #"Renamed Columns5" = Table.RenameColumns(#"Changed Type5",{{"AddInfo.SupportsMiBps.1", "AddInfo.SupportsMiBpsResult"}, {"AddInfo.SupportsMiBps.2", "AddInfo.SupportsMiBpsDetails"}}), 27 | #"Split Column by Delimiter6" = Table.SplitColumn(#"Renamed Columns5", "AddInfo.BelowCPUThreshold", Splitter.SplitTextByDelimiter(":", QuoteStyle.None), {"AddInfo.BelowCPUThreshold.1", "AddInfo.BelowCPUThreshold.2"}), 28 | #"Changed Type6" = Table.TransformColumnTypes(#"Split Column by Delimiter6",{{"AddInfo.BelowCPUThreshold.1", type text}, {"AddInfo.BelowCPUThreshold.2", type text}}), 29 | #"Renamed Columns6" = Table.RenameColumns(#"Changed Type6",{{"AddInfo.BelowCPUThreshold.1", "AddInfo.BelowCPUThresholdResult"}, {"AddInfo.BelowCPUThreshold.2", "AddInfo.BelowCPUThresholdDetails"}}), 30 | #"Changed Type7" = Table.TransformColumnTypes(#"Renamed Columns6",{{"AddInfo.CostsAmount", type number}}, "en-US"), 31 | #"Changed Type8" = Table.TransformColumnTypes(#"Changed Type7",{{"AddInfo.savingsAmount", type number}}, "en-US"), 32 | #"Changed Type9" = Table.TransformColumnTypes(#"Changed Type8",{{"AddInfo.DiskSizeGB", Int64.Type}}), 33 | #"Changed Type10" = Table.TransformColumnTypes(#"Changed Type9",{{"AddInfo.MaxMemoryP95", Int64.Type}}), 34 | #"Changed Type11" = Table.TransformColumnTypes(#"Changed Type10",{{"AddInfo.MaxCpuP95", Int64.Type}}), 35 | #"Changed Type12" = Table.TransformColumnTypes(#"Changed Type11",{{"AddInfo.MaxTotalNetworkP95", Int64.Type}}), 36 | #"Changed Type13" = Table.TransformColumnTypes(#"Changed Type12",{{"AddInfo.annualSavingsAmount", type number}}, "en-US"), 37 | #"Changed Type14" = Table.TransformColumnTypes(#"Changed Type13",{{"AddInfo.MetricCPUPercentage", type number}}, "en-US"), 38 | #"Changed Type15" = Table.TransformColumnTypes(#"Changed Type14",{{"AddInfo.NicCount", Int64.Type}}), 39 | #"Changed Type16" = Table.TransformColumnTypes(#"Changed Type15",{{"AddInfo.DataDiskCount", Int64.Type}}), 40 | #"Changed Type17" = Table.TransformColumnTypes(#"Changed Type16",{{"AddInfo.MetricMiBps", type number}}, "en-US"), 41 | #"Changed Type18" = Table.TransformColumnTypes(#"Changed Type17",{{"AddInfo.MetricIOPS", type number}}, "en-US"), 42 | #"Changed Type19" = Table.TransformColumnTypes(#"Changed Type18",{{"AddInfo.MetricNetworkMbps", type number}}, "en-US"), 43 | #"Changed Type20" = Table.TransformColumnTypes(#"Changed Type19",{{"AddInfo.MetricMemoryPercentage", type number}}, "en-US") 44 | in 45 | #"Changed Type20" -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGSqlDatabasePropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope 23 | if ([string]::IsNullOrEmpty($referenceRegion)) 24 | { 25 | $referenceRegion = "westeurope" 26 | } 27 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 28 | if ([string]::IsNullOrEmpty($authenticationOption)) 29 | { 30 | $authenticationOption = "ManagedIdentity" 31 | } 32 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 33 | { 34 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 35 | } 36 | 37 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 38 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 39 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 40 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 41 | if (-not($storageAccountSinkEnv)) 42 | { 43 | $storageAccountSinkEnv = $cloudEnvironment 44 | } 45 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 46 | $storageAccountSinkKey = $null 47 | if ($storageAccountSinkKeyCred) 48 | { 49 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 50 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 51 | } 52 | 53 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGSqlDatabaseContainer" -ErrorAction SilentlyContinue 54 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 55 | { 56 | $storageAccountSinkContainer = "argsqldbexports" 57 | } 58 | 59 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 60 | { 61 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 62 | } 63 | 64 | $ARGPageSize = 1000 65 | 66 | "Logging in to Azure with $authenticationOption..." 67 | 68 | switch ($authenticationOption) { 69 | "UserAssignedManagedIdentity" { 70 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 71 | break 72 | } 73 | Default { #ManagedIdentity 74 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 75 | break 76 | } 77 | } 78 | 79 | if (-not($storageAccountSinkKey)) 80 | { 81 | Write-Output "Getting Storage Account context with login" 82 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 83 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 84 | } 85 | else 86 | { 87 | Write-Output "Getting Storage Account context with key" 88 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 89 | } 90 | 91 | $cloudSuffix = "" 92 | 93 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 94 | { 95 | "Logging in to Azure with $externalCredentialName external credential..." 96 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 97 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 98 | $cloudEnvironment = $externalCloudEnvironment 99 | } 100 | 101 | $tenantId = (Get-AzContext).Tenant.Id 102 | 103 | $alldbs = @() 104 | 105 | Write-Output "Getting subscriptions target $TargetSubscription" 106 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 107 | { 108 | $subscriptions = $TargetSubscription 109 | $subscriptionSuffix = $TargetSubscription 110 | } 111 | else 112 | { 113 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 114 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 115 | } 116 | 117 | $dbsTotal = @() 118 | 119 | $resultsSoFar = 0 120 | 121 | Write-Output "Querying for SQL Databases properties" 122 | 123 | $argQuery = @" 124 | resources 125 | | where type =~ 'microsoft.sql/servers/databases' and name != 'master' 126 | | extend skuName = sku.name, skuTier = sku.tier, skuCapacity = sku.capacity 127 | | extend storageAccountType = properties.storageAccountType, licenseType = properties.licenseType, serviceObjectiveName = properties.currentServiceObjectiveName 128 | | extend zoneRedundant = properties.zoneRedundant, maxSizeBytes = properties.maxSizeBytes, maxLogSizeBytes = properties.maxLogSizeBytes 129 | | order by id asc 130 | "@ 131 | 132 | do 133 | { 134 | if ($resultsSoFar -eq 0) 135 | { 136 | $dbs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 137 | } 138 | else 139 | { 140 | $dbs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 141 | } 142 | if ($dbs -and $dbs.GetType().Name -eq "PSResourceGraphResponse") 143 | { 144 | $dbs = $dbs.Data 145 | } 146 | $resultsCount = $dbs.Count 147 | $resultsSoFar += $resultsCount 148 | $dbsTotal += $dbs 149 | 150 | } while ($resultsCount -eq $ARGPageSize) 151 | 152 | $datetime = (Get-Date).ToUniversalTime() 153 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 154 | $statusDate = $datetime.ToString("yyyy-MM-dd") 155 | 156 | Write-Output "Building $($dbsTotal.Count) SQL Database entries" 157 | 158 | foreach ($db in $dbsTotal) 159 | { 160 | $logentry = New-Object PSObject -Property @{ 161 | Timestamp = $timestamp 162 | Cloud = $cloudEnvironment 163 | TenantGuid = $db.tenantId 164 | SubscriptionGuid = $db.subscriptionId 165 | ResourceGroupName = $db.resourceGroup.ToLower() 166 | ZoneRedundant = $db.zoneRedundant 167 | Location = $db.location 168 | DBName = $db.name.ToLower() 169 | InstanceId = $db.id.ToLower() 170 | SkuName = $db.skuName 171 | SkuTier = $db.skuTier 172 | SkuCapacity = $db.skuCapacity 173 | ServiceObjectiveName = $db.serviceObjectiveName 174 | StorageAccountType = $db.storageAccountType 175 | LicenseType = $db.licenseType 176 | MaxSizeBytes = $db.maxSizeBytes 177 | MaxLogSizeBytes = $db.maxLogSizeBytes 178 | Tags = $db.tags 179 | StatusDate = $statusDate 180 | } 181 | 182 | $alldbs += $logentry 183 | } 184 | 185 | Write-Output "Uploading CSV to Storage" 186 | 187 | $today = $datetime.ToString("yyyyMMdd") 188 | $csvExportPath = "$today-sqldbs-$subscriptionSuffix.csv" 189 | 190 | $alldbs | Export-Csv -Path $csvExportPath -NoTypeInformation 191 | 192 | $csvBlobName = $csvExportPath 193 | 194 | $csvProperties = @{"ContentType" = "text/csv"}; 195 | 196 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 197 | 198 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 199 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 200 | 201 | Remove-Item -Path $csvExportPath -Force 202 | 203 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 204 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGAppServicePlanPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope 23 | if ([string]::IsNullOrEmpty($referenceRegion)) 24 | { 25 | $referenceRegion = "westeurope" 26 | } 27 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 28 | if ([string]::IsNullOrEmpty($authenticationOption)) 29 | { 30 | $authenticationOption = "ManagedIdentity" 31 | } 32 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 33 | { 34 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 35 | } 36 | 37 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 38 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 39 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 40 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 41 | if (-not($storageAccountSinkEnv)) 42 | { 43 | $storageAccountSinkEnv = $cloudEnvironment 44 | } 45 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 46 | $storageAccountSinkKey = $null 47 | if ($storageAccountSinkKeyCred) 48 | { 49 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 50 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 51 | } 52 | 53 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGAppServicePlanContainer" -ErrorAction SilentlyContinue 54 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 55 | { 56 | $storageAccountSinkContainer = "argappserviceplanexports" 57 | } 58 | 59 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 60 | { 61 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 62 | } 63 | 64 | $ARGPageSize = 1000 65 | 66 | "Logging in to Azure with $authenticationOption..." 67 | 68 | switch ($authenticationOption) { 69 | "UserAssignedManagedIdentity" { 70 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 71 | break 72 | } 73 | Default { #ManagedIdentity 74 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 75 | break 76 | } 77 | } 78 | 79 | if (-not($storageAccountSinkKey)) 80 | { 81 | Write-Output "Getting Storage Account context with login" 82 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 83 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 84 | } 85 | else 86 | { 87 | Write-Output "Getting Storage Account context with key" 88 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 89 | } 90 | 91 | $cloudSuffix = "" 92 | 93 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 94 | { 95 | "Logging in to Azure with $externalCredentialName external credential..." 96 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 97 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 98 | $cloudEnvironment = $externalCloudEnvironment 99 | } 100 | 101 | $tenantId = (Get-AzContext).Tenant.Id 102 | 103 | $allasp = @() 104 | 105 | Write-Output "Getting subscriptions target $TargetSubscription" 106 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 107 | { 108 | $subscriptions = $TargetSubscription 109 | $subscriptionSuffix = $TargetSubscription 110 | } 111 | else 112 | { 113 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 114 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 115 | } 116 | 117 | $aspTotal = @() 118 | 119 | $resultsSoFar = 0 120 | 121 | Write-Output "Querying for App Service Plan properties" 122 | 123 | $argQuery = @" 124 | resources 125 | | where type =~ 'microsoft.web/serverfarms' 126 | | extend skuName = sku.name, skuTier = sku.tier, skuCapacity = sku.capacity, skuFamily = sku.family, skuSize = sku.size 127 | | extend computeMode = properties.computeMode, zoneRedundant = properties.zoneRedundant 128 | | extend numberOfWorkers = properties.numberOfWorkers, currentNumberOfWorkers = properties.currentNumberOfWorkers, maximumNumberOfWorkers = properties.maximumNumberOfWorkers 129 | | extend numberOfSites = properties.numberOfSites, planName = properties.planName 130 | | order by id asc 131 | "@ 132 | 133 | do 134 | { 135 | if ($resultsSoFar -eq 0) 136 | { 137 | $asp = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 138 | } 139 | else 140 | { 141 | $asp = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 142 | } 143 | if ($asp -and $asp.GetType().Name -eq "PSResourceGraphResponse") 144 | { 145 | $asp = $asp.Data 146 | } 147 | $resultsCount = $asp.Count 148 | $resultsSoFar += $resultsCount 149 | $aspTotal += $asp 150 | 151 | } while ($resultsCount -eq $ARGPageSize) 152 | 153 | $datetime = (Get-Date).ToUniversalTime() 154 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 155 | $statusDate = $datetime.ToString("yyyy-MM-dd") 156 | 157 | Write-Output "Building $($aspTotal.Count) App Service Plan entries" 158 | 159 | foreach ($asplan in $aspTotal) 160 | { 161 | $logentry = New-Object PSObject -Property @{ 162 | Timestamp = $timestamp 163 | Cloud = $cloudEnvironment 164 | TenantGuid = $asplan.tenantId 165 | SubscriptionGuid = $asplan.subscriptionId 166 | ResourceGroupName = $asplan.resourceGroup.ToLower() 167 | ZoneRedundant = $asplan.zoneRedundant 168 | Location = $asplan.location 169 | AppServicePlanName = $asplan.name.ToLower() 170 | InstanceId = $asplan.id.ToLower() 171 | Kind = $asplan.kind 172 | SkuName = $asplan.skuName 173 | SkuTier = $asplan.skuTier 174 | SkuCapacity = $asplan.skuCapacity 175 | SkuFamily = $asplan.skuFamily 176 | SkuSize = $asplan.skuSize 177 | ComputeMode = $asplan.computeMode 178 | NumberOfWorkers = $asplan.numberOfWorkers 179 | CurrentNumberOfWorkers = $asplan.currentNumberOfWorkers 180 | MaximumNumberOfWorkers = $asplan.maximumNumberOfWorkers 181 | NumberOfSites = $asplan.numberOfSites 182 | PlanName = $asplan.planName 183 | Tags = $asplan.tags 184 | StatusDate = $statusDate 185 | } 186 | 187 | $allasp += $logentry 188 | } 189 | 190 | Write-Output "Uploading CSV to Storage" 191 | 192 | $today = $datetime.ToString("yyyyMMdd") 193 | $csvExportPath = "$today-asp-$subscriptionSuffix.csv" 194 | 195 | $allasp | Export-Csv -Path $csvExportPath -NoTypeInformation 196 | 197 | $csvBlobName = $csvExportPath 198 | 199 | $csvProperties = @{"ContentType" = "text/csv"}; 200 | 201 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 202 | 203 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 204 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 205 | 206 | Remove-Item -Path $csvExportPath -Force 207 | 208 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 209 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGManagedDisksPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 23 | if ([string]::IsNullOrEmpty($authenticationOption)) 24 | { 25 | $authenticationOption = "ManagedIdentity" 26 | } 27 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 28 | { 29 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 30 | } 31 | 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 36 | if (-not($storageAccountSinkEnv)) 37 | { 38 | $storageAccountSinkEnv = $cloudEnvironment 39 | } 40 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 41 | $storageAccountSinkKey = $null 42 | if ($storageAccountSinkKeyCred) 43 | { 44 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 45 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 46 | } 47 | 48 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGDiskContainer" -ErrorAction SilentlyContinue 49 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 50 | { 51 | $storageAccountSinkContainer = "argdiskexports" 52 | } 53 | 54 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 55 | { 56 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 57 | } 58 | 59 | $ARGPageSize = 1000 60 | 61 | "Logging in to Azure with $authenticationOption..." 62 | 63 | switch ($authenticationOption) { 64 | "UserAssignedManagedIdentity" { 65 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 66 | break 67 | } 68 | Default { #ManagedIdentity 69 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 70 | break 71 | } 72 | } 73 | 74 | if (-not($storageAccountSinkKey)) 75 | { 76 | Write-Output "Getting Storage Account context with login" 77 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 78 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 79 | } 80 | else 81 | { 82 | Write-Output "Getting Storage Account context with key" 83 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 84 | } 85 | 86 | $cloudSuffix = "" 87 | 88 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 89 | { 90 | "Logging in to Azure with $externalCredentialName external credential..." 91 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 92 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 93 | $cloudEnvironment = $externalCloudEnvironment 94 | } 95 | 96 | $tenantId = (Get-AzContext).Tenant.Id 97 | 98 | $alldisks = @() 99 | 100 | Write-Output "Getting subscriptions target $TargetSubscription" 101 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 102 | { 103 | $subscriptions = $TargetSubscription 104 | $subscriptionSuffix = $TargetSubscription 105 | } 106 | else 107 | { 108 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 109 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 110 | } 111 | 112 | $mdisksTotal = @() 113 | $resultsSoFar = 0 114 | 115 | <# 116 | Getting all Managed Disks properties with Azure Resource Graph query 117 | #> 118 | 119 | Write-Output "Querying for ARM Managed Disks properties" 120 | 121 | $argQuery = @" 122 | resources 123 | | where type =~ 'Microsoft.Compute/disks' 124 | | extend DiskId = tolower(id), OwnerVmId = tolower(managedBy) 125 | | join kind=leftouter ( 126 | resources 127 | | where type =~ 'Microsoft.Compute/virtualMachines' and array_length(properties.storageProfile.dataDisks) > 0 128 | | extend OwnerVmId = tolower(id) 129 | | mv-expand DataDisks = properties.storageProfile.dataDisks 130 | | extend DiskId = tolower(DataDisks.managedDisk.id), diskCaching = tostring(DataDisks.caching), diskType = 'Data' 131 | | project DiskId, OwnerVmId, diskCaching, diskType 132 | | union ( 133 | resources 134 | | where type =~ 'Microsoft.Compute/virtualMachines' 135 | | extend OwnerVmId = tolower(id) 136 | | extend DiskId = tolower(properties.storageProfile.osDisk.managedDisk.id), diskCaching = tostring(properties.storageProfile.osDisk.caching), diskType = 'OS' 137 | | project DiskId, OwnerVmId, diskCaching, diskType 138 | ) 139 | ) on OwnerVmId, DiskId 140 | | project-away OwnerVmId, DiskId, OwnerVmId1, DiskId1 141 | | order by id asc 142 | "@ 143 | 144 | do 145 | { 146 | if ($resultsSoFar -eq 0) 147 | { 148 | $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 149 | } 150 | else 151 | { 152 | $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 153 | } 154 | if ($mdisks -and $mdisks.GetType().Name -eq "PSResourceGraphResponse") 155 | { 156 | $mdisks = $mdisks.Data 157 | } 158 | $resultsCount = $mdisks.Count 159 | $resultsSoFar += $resultsCount 160 | $mdisksTotal += $mdisks 161 | 162 | } while ($resultsCount -eq $ARGPageSize) 163 | 164 | Write-Output "Found $($mdisksTotal.Count) Managed Disk entries" 165 | 166 | <# 167 | Building CSV entries 168 | #> 169 | 170 | $datetime = (get-date).ToUniversalTime() 171 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 172 | $statusDate = $datetime.ToString("yyyy-MM-dd") 173 | 174 | foreach ($disk in $mdisksTotal) 175 | { 176 | $ownerVmId = $null 177 | if ($null -ne $disk.managedBy) 178 | { 179 | $ownerVmId = $disk.managedBy.ToLower() 180 | } 181 | 182 | $logentry = New-Object PSObject -Property @{ 183 | Timestamp = $timestamp 184 | Cloud = $cloudEnvironment 185 | TenantGuid = $disk.tenantId 186 | SubscriptionGuid = $disk.subscriptionId 187 | ResourceGroupName = $disk.resourceGroup.ToLower() 188 | DiskName = $disk.name.ToLower() 189 | InstanceId = $disk.id.ToLower() 190 | Location = $disk.location 191 | OwnerVMId = $ownerVmId 192 | DeploymentModel = "Managed" 193 | DiskType = $disk.diskType 194 | TimeCreated = $disk.properties.timeCreated 195 | DiskIOPS = $disk.properties.diskIOPSReadWrite 196 | DiskThroughput = $disk.properties.diskMBpsReadWrite 197 | DiskTier = $disk.properties.tier 198 | DiskState = $disk.properties.diskState 199 | EncryptionType = $disk.properties.encryption.type 200 | Zones = $disk.zones 201 | Caching = $disk.diskCaching 202 | DiskSizeGB = $disk.properties.diskSizeGB 203 | SKU = $disk.sku.name 204 | StatusDate = $statusDate 205 | Tags = $disk.tags 206 | } 207 | 208 | $alldisks += $logentry 209 | } 210 | 211 | <# 212 | Actually exporting CSV to Azure Storage 213 | #> 214 | 215 | $today = $datetime.ToString("yyyyMMdd") 216 | $csvExportPath = "$today-disks-$subscriptionSuffix.csv" 217 | 218 | $alldisks | Export-Csv -Path $csvExportPath -NoTypeInformation 219 | 220 | $csvBlobName = $csvExportPath 221 | 222 | $csvProperties = @{"ContentType" = "text/csv"}; 223 | 224 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 225 | 226 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 227 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 228 | 229 | Remove-Item -Path $csvExportPath -Force 230 | 231 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 232 | Write-Output "[$now] Removed $csvExportPath from local disk..." 233 | -------------------------------------------------------------------------------- /runbooks/recommendations/Ingest-SuppressionsToLogAnalytics.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 4 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 5 | { 6 | $cloudEnvironment = "AzureCloud" 7 | } 8 | 9 | $workspaceId = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceId" 10 | $sharedKey = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsWorkspaceKey" 11 | $LogAnalyticsChunkSize = [int] (Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsChunkSize" -ErrorAction SilentlyContinue) 12 | if (-not($LogAnalyticsChunkSize -gt 0)) 13 | { 14 | $LogAnalyticsChunkSize = 6000 15 | } 16 | $lognamePrefix = Get-AutomationVariable -Name "AzureOptimization_LogAnalyticsLogPrefix" -ErrorAction SilentlyContinue 17 | if ([string]::IsNullOrEmpty($lognamePrefix)) 18 | { 19 | $lognamePrefix = "AzureOptimization" 20 | } 21 | 22 | $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" 23 | $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" 24 | $SqlUsername = $sqlserverCredential.UserName 25 | $SqlPass = $sqlserverCredential.GetNetworkCredential().Password 26 | $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue 27 | if ([string]::IsNullOrEmpty($sqldatabase)) 28 | { 29 | $sqldatabase = "azureoptimization" 30 | } 31 | 32 | $SqlTimeout = 300 33 | $FiltersTable = "Filters" 34 | 35 | #region Functions 36 | 37 | # Function to create the authorization signature 38 | Function Build-OMSSignature ($workspaceId, $sharedKey, $date, $contentLength, $method, $contentType, $resource) { 39 | $xHeaders = "x-ms-date:" + $date 40 | $stringToHash = $method + "`n" + $contentLength + "`n" + $contentType + "`n" + $xHeaders + "`n" + $resource 41 | $bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash) 42 | $keyBytes = [Convert]::FromBase64String($sharedKey) 43 | $sha256 = New-Object System.Security.Cryptography.HMACSHA256 44 | $sha256.Key = $keyBytes 45 | $calculatedHash = $sha256.ComputeHash($bytesToHash) 46 | $encodedHash = [Convert]::ToBase64String($calculatedHash) 47 | $authorization = 'SharedKey {0}:{1}' -f $workspaceId, $encodedHash 48 | return $authorization 49 | } 50 | 51 | # Function to create and post the request 52 | Function Post-OMSData($workspaceId, $sharedKey, $body, $logType, $TimeStampField, $AzureEnvironment) { 53 | $method = "POST" 54 | $contentType = "application/json" 55 | $resource = "/api/logs" 56 | $rfc1123date = [DateTime]::UtcNow.ToString("r") 57 | $contentLength = $body.Length 58 | $signature = Build-OMSSignature ` 59 | -workspaceId $workspaceId ` 60 | -sharedKey $sharedKey ` 61 | -date $rfc1123date ` 62 | -contentLength $contentLength ` 63 | -method $method ` 64 | -contentType $contentType ` 65 | -resource $resource 66 | 67 | $uri = "https://" + $workspaceId + ".ods.opinsights.azure.com" + $resource + "?api-version=2016-04-01" 68 | if ($AzureEnvironment -eq "AzureChinaCloud") 69 | { 70 | $uri = "https://" + $workspaceId + ".ods.opinsights.azure.cn" + $resource + "?api-version=2016-04-01" 71 | } 72 | if ($AzureEnvironment -eq "AzureUSGovernment") 73 | { 74 | $uri = "https://" + $workspaceId + ".ods.opinsights.azure.us" + $resource + "?api-version=2016-04-01" 75 | } 76 | if ($AzureEnvironment -eq "AzureGermanCloud") 77 | { 78 | throw "Azure Germany isn't suported for the Log Analytics Data Collector API" 79 | } 80 | 81 | $OMSheaders = @{ 82 | "Authorization" = $signature; 83 | "Log-Type" = $logType; 84 | "x-ms-date" = $rfc1123date; 85 | "time-generated-field" = $TimeStampField; 86 | } 87 | 88 | Try { 89 | 90 | $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 91 | } 92 | catch { 93 | if ($_.Exception.Response.StatusCode.Value__ -eq 401) { 94 | "REAUTHENTICATING" 95 | 96 | $response = Invoke-WebRequest -Uri $uri -Method POST -ContentType $contentType -Headers $OMSheaders -Body $body -UseBasicParsing -TimeoutSec 1000 97 | } 98 | else 99 | { 100 | return $_.Exception.Response.StatusCode.Value__ 101 | } 102 | } 103 | 104 | return $response.StatusCode 105 | } 106 | #endregion Functions 107 | 108 | Write-Output "Getting excluded recommendation sub-type IDs..." 109 | 110 | $tries = 0 111 | $connectionSuccess = $false 112 | do { 113 | $tries++ 114 | try { 115 | $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") 116 | $Conn.Open() 117 | $Cmd=new-object system.Data.SqlClient.SqlCommand 118 | $Cmd.Connection = $Conn 119 | $Cmd.CommandTimeout = $SqlTimeout 120 | $Cmd.CommandText = "SELECT * FROM [dbo].[$FiltersTable] WHERE IsEnabled = 1 AND (FilterEndDate IS NULL OR FilterEndDate > GETDATE())" 121 | 122 | $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter 123 | $sqlAdapter.SelectCommand = $Cmd 124 | $filters = New-Object System.Data.DataTable 125 | $sqlAdapter.Fill($filters) | Out-Null 126 | $connectionSuccess = $true 127 | } 128 | catch { 129 | Write-Output "Failed to contact SQL at try $tries." 130 | Write-Output $Error[0] 131 | Start-Sleep -Seconds ($tries * 20) 132 | } 133 | } while (-not($connectionSuccess) -and $tries -lt 3) 134 | 135 | if (-not($connectionSuccess)) 136 | { 137 | throw "Could not establish connection to SQL." 138 | } 139 | 140 | $Conn.Close() 141 | $Conn.Dispose() 142 | 143 | $datetime = (get-date).ToUniversalTime() 144 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 145 | 146 | $filterObjects = @() 147 | 148 | $filterObject = New-Object PSObject -Property @{ 149 | Timestamp = $timestamp 150 | FilterId = (New-Guid).Guid 151 | RecommendationSubTypeId = [System.Guid]::empty.Guid 152 | FilterType = "Dummy" 153 | InstanceId = [System.Guid]::empty.Guid 154 | InstanceName = "Dummy" 155 | FilterStartDate = "2019-01-01T00:00:00.000Z" 156 | FilterEndDate = "2199-12-31T23:59:59.000Z" 157 | Author = "AOE" 158 | Notes = "This is a dummy suppression required to build the full suppressions schema in Log Analytics" 159 | } 160 | $filterObjects += $filterObject 161 | 162 | foreach ($filter in $filters) 163 | { 164 | $filterEndDate = $null 165 | if (-not([string]::IsNullOrEmpty($filter.FilterEndDate))) 166 | { 167 | Write-Output $filter.FilterEndDate 168 | $filterEndDate = $filter.FilterEndDate.ToString("yyyy-MM-ddTHH:mm:00.000Z") 169 | } 170 | else 171 | { 172 | $filterEndDate = "2199-12-31T23:59:59.000Z" 173 | } 174 | 175 | $filterStartDate = $null 176 | if (-not([string]::IsNullOrEmpty($filter.FilterStartDate))) 177 | { 178 | $filterStartDate = $filter.FilterStartDate.ToString("yyyy-MM-ddTHH:mm:00.000Z") 179 | } 180 | else 181 | { 182 | $filterStartDate = "2019-01-01T00:00:00.000Z" 183 | } 184 | 185 | $instanceId = $null 186 | $instanceName = $null 187 | $ObjectGuid = [System.Guid]::empty 188 | if ([System.Guid]::TryParse($filter.InstanceId, [System.Management.Automation.PSReference]$ObjectGuid)) 189 | { 190 | $instanceId = $filter.InstanceId 191 | } 192 | else 193 | { 194 | $instanceName = $filter.InstanceId 195 | } 196 | 197 | $filterObject = New-Object PSObject -Property @{ 198 | Timestamp = $timestamp 199 | FilterId = $filter.FilterId 200 | RecommendationSubTypeId = $filter.RecommendationSubTypeId 201 | FilterType = $filter.FilterType 202 | InstanceId = $instanceId 203 | InstanceName = $instanceName 204 | FilterStartDate = $filterStartDate 205 | FilterEndDate = $filterEndDate 206 | Author = $filter.Author 207 | Notes = $filter.Notes 208 | } 209 | $filterObjects += $filterObject 210 | } 211 | 212 | $filtersJson = $filterObjects | ConvertTo-Json 213 | 214 | $LogAnalyticsSuffix = "SuppressionsV1" 215 | $logname = $lognamePrefix + $LogAnalyticsSuffix 216 | 217 | $res = Post-OMSData -workspaceId $workspaceId -sharedKey $sharedKey -body ([System.Text.Encoding]::UTF8.GetBytes($filtersJson)) -logType $logname -TimeStampField "Timestamp" -AzureEnvironment $cloudEnvironment 218 | If ($res -ge 200 -and $res -lt 300) { 219 | Write-Output "Succesfully uploaded $($filterObjects.Count) $LogAnalyticsSuffix rows to Log Analytics" 220 | } 221 | Else { 222 | Write-Warning "Failed to upload $($filterObjects.Count) $LogAnalyticsSuffix rows. Error code: $res" 223 | throw 224 | } 225 | -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGLoadBalancerPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 23 | if ([string]::IsNullOrEmpty($authenticationOption)) 24 | { 25 | $authenticationOption = "ManagedIdentity" 26 | } 27 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 28 | { 29 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 30 | } 31 | 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 36 | if (-not($storageAccountSinkEnv)) 37 | { 38 | $storageAccountSinkEnv = $cloudEnvironment 39 | } 40 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 41 | $storageAccountSinkKey = $null 42 | if ($storageAccountSinkKeyCred) 43 | { 44 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 45 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 46 | } 47 | 48 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGLoadBalancerContainer" -ErrorAction SilentlyContinue 49 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 50 | { 51 | $storageAccountSinkContainer = "arglbexports" 52 | } 53 | 54 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 55 | { 56 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 57 | } 58 | 59 | $ARGPageSize = 1000 60 | 61 | "Logging in to Azure with $authenticationOption..." 62 | 63 | switch ($authenticationOption) { 64 | "UserAssignedManagedIdentity" { 65 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 66 | break 67 | } 68 | Default { #ManagedIdentity 69 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 70 | break 71 | } 72 | } 73 | 74 | if (-not($storageAccountSinkKey)) 75 | { 76 | Write-Output "Getting Storage Account context with login" 77 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 78 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 79 | } 80 | else 81 | { 82 | Write-Output "Getting Storage Account context with key" 83 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 84 | } 85 | 86 | $cloudSuffix = "" 87 | 88 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 89 | { 90 | "Logging in to Azure with $externalCredentialName external credential..." 91 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 92 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 93 | $cloudEnvironment = $externalCloudEnvironment 94 | } 95 | 96 | $tenantId = (Get-AzContext).Tenant.Id 97 | 98 | $allLBs = @() 99 | 100 | Write-Output "Getting subscriptions target $TargetSubscription" 101 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 102 | { 103 | $subscriptions = $TargetSubscription 104 | $subscriptionSuffix = $TargetSubscription 105 | } 106 | else 107 | { 108 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 109 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 110 | } 111 | 112 | $LBsTotal = @() 113 | $resultsSoFar = 0 114 | 115 | Write-Output "Querying for Load Balancer properties" 116 | 117 | $argQuery = @" 118 | resources 119 | | where type =~ 'Microsoft.Network/loadBalancers' 120 | | extend lbType = iif(properties.frontendIPConfigurations contains 'publicIPAddress', 'Public', iif(properties.frontendIPConfigurations contains 'privateIPAddress', 'Internal', 'Unknown')) 121 | | extend lbRulesCount = array_length(properties.loadBalancingRules) 122 | | extend frontendIPsCount = array_length(properties.frontendIPConfigurations) 123 | | extend inboundNatRulesCount = array_length(properties.inboundNatRules) 124 | | extend outboundRulesCount = array_length(properties.outboundRules) 125 | | extend inboundNatPoolsCount = array_length(properties.inboundNatPools) 126 | | extend backendPoolsCount = array_length(properties.backendAddressPools) 127 | | extend probesCount = array_length(properties.probes) 128 | | project id, name, resourceGroup, subscriptionId, tenantId, location, skuName = sku.name, skuTier = sku.tier, lbType, lbRulesCount, frontendIPsCount, inboundNatRulesCount, outboundRulesCount, inboundNatPoolsCount, backendPoolsCount, probesCount, tags 129 | | join kind=leftouter ( 130 | resources 131 | | where type =~ 'Microsoft.Network/loadBalancers' 132 | | mvexpand backendPools = properties.backendAddressPools 133 | | extend backendIPCount = array_length(backendPools.properties.backendIPConfigurations) 134 | | extend backendAddressesCount = array_length(backendPools.properties.loadBalancerBackendAddresses) 135 | | summarize backendIPCount = sum(backendIPCount), backendAddressesCount = sum(backendAddressesCount) by id 136 | ) on id 137 | | project-away id1 138 | | order by id asc 139 | "@ 140 | 141 | do 142 | { 143 | if ($resultsSoFar -eq 0) 144 | { 145 | $LBs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 146 | } 147 | else 148 | { 149 | $LBs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 150 | } 151 | if ($LBs -and $LBs.GetType().Name -eq "PSResourceGraphResponse") 152 | { 153 | $LBs = $LBs.Data 154 | } 155 | $resultsCount = $LBs.Count 156 | $resultsSoFar += $resultsCount 157 | $LBsTotal += $LBs 158 | 159 | } while ($resultsCount -eq $ARGPageSize) 160 | 161 | Write-Output "Found $($LBsTotal.Count) Load Balancer entries" 162 | 163 | <# 164 | Building CSV entries 165 | #> 166 | 167 | $datetime = (get-date).ToUniversalTime() 168 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 169 | $statusDate = $datetime.ToString("yyyy-MM-dd") 170 | 171 | foreach ($lb in $LBsTotal) 172 | { 173 | $logentry = New-Object PSObject -Property @{ 174 | Timestamp = $timestamp 175 | Cloud = $cloudEnvironment 176 | TenantGuid = $lb.tenantId 177 | SubscriptionGuid = $lb.subscriptionId 178 | ResourceGroupName = $lb.resourceGroup.ToLower() 179 | InstanceName = $lb.name.ToLower() 180 | InstanceId = $lb.id.ToLower() 181 | SkuName = $lb.skuName 182 | SkuTier = $lb.skuTier 183 | Location = $lb.location 184 | LbType = $lb.lbType 185 | LbRulesCount = $lb.lbRulesCount 186 | InboundNatRulesCount = $lb.inboundNatRulesCount 187 | OutboundRulesCount = $lb.outboundRulesCount 188 | FrontendIPsCount = $lb.frontendIPsCount 189 | BackendIPCount = $lb.backendIPCount 190 | BackendAddressesCount = $lb.backendAddressesCount 191 | InboundNatPoolsCount = $lb.inboundNatPoolsCount 192 | BackendPoolsCount = $lb.backendPoolsCount 193 | ProbesCount = $lb.probesCount 194 | StatusDate = $statusDate 195 | Tags = $lb.tags 196 | } 197 | 198 | $allLBs += $logentry 199 | } 200 | 201 | <# 202 | Actually exporting CSV to Azure Storage 203 | #> 204 | 205 | $today = $datetime.ToString("yyyyMMdd") 206 | $csvExportPath = "$today-lbs-$subscriptionSuffix.csv" 207 | 208 | $allLBs | Export-Csv -Path $csvExportPath -NoTypeInformation 209 | 210 | $csvBlobName = $csvExportPath 211 | 212 | $csvProperties = @{"ContentType" = "text/csv"}; 213 | 214 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 215 | 216 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 217 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 218 | 219 | Remove-Item -Path $csvExportPath -Force 220 | 221 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 222 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/remediations/Remediate-AdvisorRightSizeFiltered.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [bool] $Simulate = $true 4 | ) 5 | 6 | $ErrorActionPreference = "Stop" 7 | 8 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 9 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 10 | { 11 | $cloudEnvironment = "AzureCloud" 12 | } 13 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 14 | if ([string]::IsNullOrEmpty($authenticationOption)) 15 | { 16 | $authenticationOption = "ManagedIdentity" 17 | } 18 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 19 | { 20 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 21 | } 22 | 23 | $sqlserver = Get-AutomationVariable -Name "AzureOptimization_SQLServerHostname" 24 | $sqlserverCredential = Get-AutomationPSCredential -Name "AzureOptimization_SQLServerCredential" 25 | $SqlUsername = $sqlserverCredential.UserName 26 | $SqlPass = $sqlserverCredential.GetNetworkCredential().Password 27 | $sqldatabase = Get-AutomationVariable -Name "AzureOptimization_SQLServerDatabase" -ErrorAction SilentlyContinue 28 | if ([string]::IsNullOrEmpty($sqldatabase)) 29 | { 30 | $sqldatabase = "azureoptimization" 31 | } 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_RemediationLogsContainer" -ErrorAction SilentlyContinue 36 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) { 37 | $storageAccountSinkContainer = "remediationlogs" 38 | } 39 | 40 | $minFitScore = [double] (Get-AutomationVariable -Name "AzureOptimization_RemediateRightSizeMinFitScore" -ErrorAction SilentlyContinue) 41 | if (-not($minFitScore -gt 0.0)) { 42 | $minFitScore = 5.0 43 | } 44 | 45 | $minWeeksInARow = [int] (Get-AutomationVariable -Name "AzureOptimization_RemediateRightSizeMinWeeksInARow" -ErrorAction SilentlyContinue) 46 | if (-not($minWeeksInARow -gt 0)) { 47 | $minWeeksInARow = 4 48 | } 49 | 50 | $tagsFilter = Get-AutomationVariable -Name "AzureOptimization_RemediateRightSizeTagsFilter" -ErrorAction SilentlyContinue 51 | # example: '[ { "tagName": "a", "tagValue": "b" }, { "tagName": "c", "tagValue": "d" } ]' 52 | if (-not($tagsFilter)) { 53 | $tagsFilter = '{}' 54 | } 55 | $tagsFilter = $tagsFilter | ConvertFrom-Json 56 | 57 | $rightSizeRecommendationId = Get-AutomationVariable -Name "AzureOptimization_RecommendationAdvisorCostRightSizeId" -ErrorAction SilentlyContinue 58 | if (-not($rightSizeRecommendationId)) { 59 | $rightSizeRecommendationId = 'e10b1381-5f0a-47ff-8c7b-37bd13d7c974' 60 | } 61 | 62 | $SqlTimeout = 0 63 | $recommendationsTable = "Recommendations" 64 | 65 | "Logging in to Azure with $authenticationOption..." 66 | 67 | switch ($authenticationOption) { 68 | "UserAssignedManagedIdentity" { 69 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 70 | break 71 | } 72 | Default { #ManagedIdentity 73 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 74 | break 75 | } 76 | } 77 | 78 | # get reference to storage sink 79 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 80 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 81 | 82 | Write-Output "Querying for right-size recommendations with fit score >= $minFitScore made consecutively for the last $minWeeksInARow weeks." 83 | 84 | $tries = 0 85 | $connectionSuccess = $false 86 | do { 87 | $tries++ 88 | try { 89 | $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$sqlserver,1433;Database=$sqldatabase;User ID=$SqlUsername;Password=$SqlPass;Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") 90 | $Conn.Open() 91 | $Cmd=new-object system.Data.SqlClient.SqlCommand 92 | $Cmd.Connection = $Conn 93 | $Cmd.CommandTimeout = $SqlTimeout 94 | $Cmd.CommandText = @" 95 | SELECT InstanceId, Cloud, TenantGuid, JSON_VALUE(AdditionalInfo, '`$.currentSku') AS CurrentSKU, JSON_VALUE(AdditionalInfo, '`$.targetSku') AS TargetSKU, COUNT(InstanceId) 96 | FROM [dbo].[$recommendationsTable] 97 | WHERE RecommendationSubTypeId = '$rightSizeRecommendationId' AND FitScore >= $minFitScore AND GeneratedDate >= GETDATE()-(7*$minWeeksInARow) 98 | GROUP BY InstanceId, Cloud, TenantGuid, JSON_VALUE(AdditionalInfo, '`$.currentSku'), JSON_VALUE(AdditionalInfo, '`$.targetSku') 99 | HAVING COUNT(InstanceId) >= $minWeeksInARow 100 | "@ 101 | $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter 102 | $sqlAdapter.SelectCommand = $Cmd 103 | $vmsToRightSize = New-Object System.Data.DataTable 104 | $sqlAdapter.Fill($vmsToRightSize) | Out-Null 105 | $connectionSuccess = $true 106 | } 107 | catch { 108 | Write-Output "Failed to contact SQL at try $tries." 109 | Write-Output $Error[0] 110 | Start-Sleep -Seconds ($tries * 20) 111 | } 112 | } while (-not($connectionSuccess) -and $tries -lt 3) 113 | 114 | if (-not($connectionSuccess)) 115 | { 116 | throw "Could not establish connection to SQL." 117 | } 118 | 119 | Write-Output "Found $($vmsToRightSize.Rows.Count) remediation opportunities." 120 | 121 | $Conn.Close() 122 | $Conn.Dispose() 123 | 124 | $logEntries = @() 125 | 126 | $datetime = (get-date).ToUniversalTime() 127 | $hour = $datetime.Hour 128 | $min = $datetime.Minute 129 | $timestamp = $datetime.ToString("yyyy-MM-ddT$($hour):$($min):00.000Z") 130 | 131 | $ctx = Get-AzContext 132 | 133 | foreach ($vm in $vmsToRightSize.Rows) 134 | { 135 | $isEligible = $false 136 | $logDetails = $null 137 | if ([string]::IsNullOrEmpty($tagsFilter)) 138 | { 139 | $isEligible = $true 140 | } 141 | else 142 | { 143 | $vmTags = Get-AzTag -ResourceId $vm.InstanceId -ErrorAction SilentlyContinue 144 | if ($vmTags) 145 | { 146 | foreach ($tagFilter in $tagsFilter) 147 | { 148 | if ($vmTags.Properties.TagsProperty.($tagFilter.tagName) -eq $tagFilter.tagValue) 149 | { 150 | $isEligible = $true 151 | } 152 | else 153 | { 154 | $isEligible = $false 155 | break 156 | } 157 | } 158 | } 159 | } 160 | 161 | $subscriptionId = $vm.InstanceId.Split("/")[2] 162 | $resourceGroup = $vm.InstanceId.Split("/")[4] 163 | $instanceName = $vm.InstanceId.Split("/")[8] 164 | 165 | if ($isEligible) 166 | { 167 | Write-Output "Downsizing (SIMULATE=$Simulate) $($vm.InstanceId) to $($vm.TargetSKU)..." 168 | if (-not($Simulate) -and $ctx.Environment.Name -eq $vm.Cloud -and $ctx.Tenant.Id -eq $vm.TenantGuid) 169 | { 170 | if ($ctx.Subscription.Id -ne $subscriptionId) 171 | { 172 | Select-AzSubscription -SubscriptionId $subscriptionId | Out-Null 173 | $ctx = Get-AzContext 174 | } 175 | $vmObj = Get-AzVM -ResourceGroupName $resourceGroup -VMName $instanceName -ErrorAction SilentlyContinue 176 | if ($vmObj) 177 | { 178 | $vmObj.HardwareProfile.VmSize = $vm.TargetSKU 179 | Update-AzVM -VM $vmObj -ResourceGroupName $resourceGroup 180 | } 181 | else 182 | { 183 | Write-Output "Skipping as VM was already removed." 184 | } 185 | } 186 | else 187 | { 188 | Write-Output "Did not apply remediation." 189 | } 190 | } 191 | 192 | $logDetails = @{ 193 | IsEligible = $isEligible 194 | CurrentSku = $vm.CurrentSKU 195 | TargetSku = $vm.TargetSKU 196 | } 197 | 198 | $logentry = New-Object PSObject -Property @{ 199 | Timestamp = $timestamp 200 | Cloud = $vm.Cloud 201 | TenantGuid = $vm.TenantGuid 202 | SubscriptionGuid = $subscriptionId 203 | ResourceGroupName = $resourceGroup.ToLower() 204 | InstanceName = $instanceName.ToLower() 205 | InstanceId = $vm.InstanceId.ToLower() 206 | Simulate = $Simulate 207 | LogDetails = $logDetails | ConvertTo-Json -Compress 208 | RecommendationSubTypeId = $rightSizeRecommendationId 209 | } 210 | 211 | $logEntries += $logentry 212 | } 213 | 214 | $today = $datetime.ToString("yyyyMMdd") 215 | $csvExportPath = "$today-rightsizefiltered.csv" 216 | 217 | $logEntries | Export-Csv -Path $csvExportPath -NoTypeInformation 218 | 219 | $csvBlobName = $csvExportPath 220 | 221 | $csvProperties = @{"ContentType" = "text/csv"}; 222 | 223 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 224 | -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGUnmanagedDisksPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 23 | if ([string]::IsNullOrEmpty($authenticationOption)) 24 | { 25 | $authenticationOption = "ManagedIdentity" 26 | } 27 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 28 | { 29 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 30 | } 31 | 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 36 | if (-not($storageAccountSinkEnv)) 37 | { 38 | $storageAccountSinkEnv = $cloudEnvironment 39 | } 40 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 41 | $storageAccountSinkKey = $null 42 | if ($storageAccountSinkKeyCred) 43 | { 44 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 45 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 46 | } 47 | 48 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGVhdContainer" -ErrorAction SilentlyContinue 49 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 50 | { 51 | $storageAccountSinkContainer = "argvhdexports" 52 | } 53 | 54 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 55 | { 56 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 57 | } 58 | 59 | $ARGPageSize = 1000 60 | 61 | "Logging in to Azure with $authenticationOption..." 62 | 63 | switch ($authenticationOption) { 64 | "UserAssignedManagedIdentity" { 65 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 66 | break 67 | } 68 | Default { #ManagedIdentity 69 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 70 | break 71 | } 72 | } 73 | 74 | if (-not($storageAccountSinkKey)) 75 | { 76 | Write-Output "Getting Storage Account context with login" 77 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 78 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 79 | } 80 | else 81 | { 82 | Write-Output "Getting Storage Account context with key" 83 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 84 | } 85 | 86 | $cloudSuffix = "" 87 | 88 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 89 | { 90 | "Logging in to Azure with $externalCredentialName external credential..." 91 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 92 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 93 | $cloudEnvironment = $externalCloudEnvironment 94 | } 95 | 96 | $tenantId = (Get-AzContext).Tenant.Id 97 | 98 | $alldisks = @() 99 | 100 | Write-Output "Getting subscriptions target $TargetSubscription" 101 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 102 | { 103 | $subscriptions = $TargetSubscription 104 | $subscriptionSuffix = $TargetSubscription 105 | } 106 | else 107 | { 108 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 109 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 110 | } 111 | 112 | $mdisksTotal = @() 113 | $resultsSoFar = 0 114 | 115 | Write-Output "Querying for ARM Unmanaged OS Disks properties" 116 | 117 | $argQuery = @" 118 | resources 119 | | where type =~ 'Microsoft.Compute/virtualMachines' and isnull(properties.storageProfile.osDisk.managedDisk) 120 | | extend diskType = 'OS', diskCaching = tostring(properties.storageProfile.osDisk.caching), diskSize = tostring(properties.storageProfile.osDisk.diskSizeGB) 121 | | extend vhdUriParts = split(tostring(properties.storageProfile.osDisk.vhd.uri),'/') 122 | | extend diskStorageAccountName = tostring(split(vhdUriParts[2],'.')[0]), diskContainerName = tostring(vhdUriParts[3]), diskVhdName = tostring(vhdUriParts[4]) 123 | | order by id, diskStorageAccountName, diskContainerName, diskVhdName 124 | "@ 125 | 126 | do 127 | { 128 | if ($resultsSoFar -eq 0) 129 | { 130 | $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 131 | } 132 | else 133 | { 134 | $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 135 | } 136 | if ($mdisks -and $mdisks.GetType().Name -eq "PSResourceGraphResponse") 137 | { 138 | $mdisks = $mdisks.Data 139 | } 140 | $resultsCount = $mdisks.Count 141 | $resultsSoFar += $resultsCount 142 | $mdisksTotal += $mdisks 143 | 144 | } while ($resultsCount -eq $ARGPageSize) 145 | 146 | $resultsSoFar = 0 147 | 148 | Write-Output "Found $($mdisksTotal.Count) Unmanaged OS Disk entries" 149 | 150 | Write-Output "Querying for ARM Unmanaged Data Disks properties" 151 | 152 | $argQuery = @" 153 | resources 154 | | where type =~ 'Microsoft.Compute/virtualMachines' and isnull(properties.storageProfile.osDisk.managedDisk) 155 | | mvexpand dataDisks = properties.storageProfile.dataDisks 156 | | extend diskType = 'Data', diskCaching = tostring(dataDisks.caching), diskSize = tostring(dataDisks.diskSizeGB) 157 | | extend vhdUriParts = split(tostring(dataDisks.vhd.uri),'/') 158 | | extend diskStorageAccountName = tostring(split(vhdUriParts[2],'.')[0]), diskContainerName = tostring(vhdUriParts[3]), diskVhdName = tostring(vhdUriParts[4]) 159 | | order by id, diskStorageAccountName, diskContainerName, diskVhdName 160 | "@ 161 | 162 | do 163 | { 164 | if ($resultsSoFar -eq 0) 165 | { 166 | $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 167 | } 168 | else 169 | { 170 | $mdisks = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 171 | } 172 | if ($mdisks -and $mdisks.GetType().Name -eq "PSResourceGraphResponse") 173 | { 174 | $mdisks = $mdisks.Data 175 | } 176 | $resultsCount = $mdisks.Count 177 | $resultsSoFar += $resultsCount 178 | $mdisksTotal += $mdisks 179 | 180 | } while ($resultsCount -eq $ARGPageSize) 181 | 182 | Write-Output "Found overall $($mdisksTotal.Count) Unmanaged Disk entries" 183 | 184 | <# 185 | Building CSV entries 186 | #> 187 | 188 | $datetime = (get-date).ToUniversalTime() 189 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 190 | $statusDate = $datetime.ToString("yyyy-MM-dd") 191 | 192 | foreach ($disk in $mdisksTotal) 193 | { 194 | $logentry = New-Object PSObject -Property @{ 195 | Timestamp = $timestamp 196 | Cloud = $cloudEnvironment 197 | TenantGuid = $disk.tenantId 198 | SubscriptionGuid = $disk.subscriptionId 199 | ResourceGroupName = $disk.resourceGroup.ToLower() 200 | DiskName = $disk.diskVhdName.ToLower() 201 | InstanceId = ($disk.diskStorageAccountName + "/" + $disk.diskContainerName + "/" + $disk.diskVhdName).ToLower() 202 | OwnerVMId = $disk.id.ToLower() 203 | Location = $disk.location 204 | DeploymentModel = "Unmanaged" 205 | DiskType = $disk.diskType 206 | Caching = $disk.diskCaching 207 | DiskSizeGB = $disk.diskSize 208 | StatusDate = $statusDate 209 | Tags = $disk.tags 210 | } 211 | 212 | $alldisks += $logentry 213 | } 214 | 215 | <# 216 | Actually exporting CSV to Azure Storage 217 | #> 218 | 219 | $today = $datetime.ToString("yyyyMMdd") 220 | $csvExportPath = "$today-vhds-$subscriptionSuffix.csv" 221 | 222 | $alldisks | Export-Csv -Path $csvExportPath -NoTypeInformation 223 | 224 | $csvBlobName = $csvExportPath 225 | 226 | $csvProperties = @{"ContentType" = "text/csv"}; 227 | 228 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 229 | 230 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 231 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 232 | 233 | Remove-Item -Path $csvExportPath -Force 234 | 235 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 236 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGNSGPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope 23 | if ([string]::IsNullOrEmpty($referenceRegion)) 24 | { 25 | $referenceRegion = "westeurope" 26 | } 27 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 28 | if ([string]::IsNullOrEmpty($authenticationOption)) 29 | { 30 | $authenticationOption = "ManagedIdentity" 31 | } 32 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 33 | { 34 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 35 | } 36 | 37 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 38 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 39 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 40 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 41 | if (-not($storageAccountSinkEnv)) 42 | { 43 | $storageAccountSinkEnv = $cloudEnvironment 44 | } 45 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 46 | $storageAccountSinkKey = $null 47 | if ($storageAccountSinkKeyCred) 48 | { 49 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 50 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 51 | } 52 | 53 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGNSGContainer" -ErrorAction SilentlyContinue 54 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 55 | { 56 | $storageAccountSinkContainer = "argnsgexports" 57 | } 58 | 59 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 60 | { 61 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 62 | } 63 | 64 | $ARGPageSize = 1000 65 | 66 | "Logging in to Azure with $authenticationOption..." 67 | 68 | switch ($authenticationOption) { 69 | "UserAssignedManagedIdentity" { 70 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 71 | break 72 | } 73 | Default { #ManagedIdentity 74 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 75 | break 76 | } 77 | } 78 | 79 | if (-not($storageAccountSinkKey)) 80 | { 81 | Write-Output "Getting Storage Account context with login" 82 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 83 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 84 | } 85 | else 86 | { 87 | Write-Output "Getting Storage Account context with key" 88 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 89 | } 90 | 91 | $cloudSuffix = "" 92 | 93 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 94 | { 95 | "Logging in to Azure with $externalCredentialName external credential..." 96 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 97 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 98 | $cloudEnvironment = $externalCloudEnvironment 99 | } 100 | 101 | $tenantId = (Get-AzContext).Tenant.Id 102 | 103 | $allnsgRules = @() 104 | 105 | Write-Output "Getting subscriptions target $TargetSubscription" 106 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 107 | { 108 | $subscriptions = $TargetSubscription 109 | $subscriptionSuffix = $TargetSubscription 110 | } 111 | else 112 | { 113 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 114 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 115 | } 116 | 117 | $nsgRulesTotal = @() 118 | 119 | $resultsSoFar = 0 120 | 121 | Write-Output "Querying for NSG properties" 122 | 123 | $argQuery = @" 124 | resources 125 | | where type =~ 'Microsoft.Network/networkSecurityGroups' 126 | | extend nicCount = iif(isnotempty(properties.networkInterfaces),array_length(properties.networkInterfaces),0) 127 | | extend subnetCount = iif(isnotempty(properties.subnets),array_length(properties.subnets),0) 128 | | mvexpand securityRules = properties.securityRules 129 | | extend ruleName = tolower(securityRules.name) 130 | | extend ruleProtocol = tolower(securityRules.properties.protocol) 131 | | extend ruleDirection = tolower(securityRules.properties.direction) 132 | | extend rulePriority = toint(securityRules.properties.priority) 133 | | extend ruleAccess = tolower(securityRules.properties.access) 134 | | extend ruleDestinationAddresses = tolower(iif(array_length(securityRules.properties.destinationAddressPrefixes) > 0,strcat_array(securityRules.properties.destinationAddressPrefixes, ','),securityRules.properties.destinationAddressPrefix)) 135 | | extend ruleSourceAddresses = tolower(iif(array_length(securityRules.properties.sourceAddressPrefixes) > 0,strcat_array(securityRules.properties.sourceAddressPrefixes, ','),securityRules.properties.sourceAddressPrefix)) 136 | | extend ruleDestinationPorts = iif(array_length(securityRules.properties.destinationPortRanges) > 0,strcat_array(securityRules.properties.destinationPortRanges, ','),securityRules.properties.destinationPortRange) 137 | | extend ruleSourcePorts = iif(array_length(securityRules.properties.sourcePortRanges) > 0,strcat_array(securityRules.properties.sourcePortRanges, ','),securityRules.properties.sourcePortRange) 138 | | extend ruleId = tolower(securityRules.id) 139 | | project-away securityRules, properties 140 | | order by ruleId asc 141 | "@ 142 | 143 | do 144 | { 145 | if ($resultsSoFar -eq 0) 146 | { 147 | $nsgRules = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 148 | } 149 | else 150 | { 151 | $nsgRules = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 152 | } 153 | if ($nsgRules -and $nsgRules.GetType().Name -eq "PSResourceGraphResponse") 154 | { 155 | $nsgRules = $nsgRules.Data 156 | } 157 | $resultsCount = $nsgRules.Count 158 | $resultsSoFar += $resultsCount 159 | $nsgRulesTotal += $nsgRules 160 | 161 | } while ($resultsCount -eq $ARGPageSize) 162 | 163 | $datetime = (Get-Date).ToUniversalTime() 164 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 165 | $statusDate = $datetime.ToString("yyyy-MM-dd") 166 | 167 | Write-Output "Building $($nsgRulesTotal.Count) ARM NSG entries" 168 | 169 | foreach ($nsgRule in $nsgRulesTotal) 170 | { 171 | $logentry = New-Object PSObject -Property @{ 172 | Timestamp = $timestamp 173 | Cloud = $cloudEnvironment 174 | TenantGuid = $nsgRule.tenantId 175 | SubscriptionGuid = $nsgRule.subscriptionId 176 | ResourceGroupName = $nsgRule.resourceGroup.ToLower() 177 | Location = $nsgRule.location 178 | NSGName = $nsgRule.name.ToLower() 179 | InstanceId = $nsgRule.id.ToLower() 180 | NicCount = $nsgRule.nicCount 181 | SubnetCount = $nsgRule.subnetCount 182 | RuleName = $nsgRule.ruleName 183 | RuleProtocol = $nsgRule.ruleProtocol 184 | RuleDirection = $nsgRule.ruleDirection 185 | RulePriority = $nsgRule.rulePriority 186 | RuleAccess = $nsgRule.ruleAccess 187 | RuleDestinationAddresses = $nsgRule.ruleDestinationAddresses 188 | RuleSourceAddresses = $nsgRule.ruleSourceAddresses 189 | RuleDestinationPorts = $nsgRule.ruleDestinationPorts 190 | RuleSourcePorts = $nsgRule.ruleSourcePorts 191 | Tags = $nsgRule.tags 192 | StatusDate = $statusDate 193 | } 194 | 195 | $allnsgRules += $logentry 196 | } 197 | 198 | Write-Output "Uploading CSV to Storage" 199 | 200 | $today = $datetime.ToString("yyyyMMdd") 201 | $csvExportPath = "$today-nsgrules-$subscriptionSuffix.csv" 202 | 203 | $allnsgRules | Export-Csv -Path $csvExportPath -NoTypeInformation 204 | 205 | $csvBlobName = $csvExportPath 206 | 207 | $csvProperties = @{"ContentType" = "text/csv"}; 208 | 209 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 210 | 211 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 212 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 213 | 214 | Remove-Item -Path $csvExportPath -Force 215 | 216 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 217 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGAppGatewayPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 23 | if ([string]::IsNullOrEmpty($authenticationOption)) 24 | { 25 | $authenticationOption = "ManagedIdentity" 26 | } 27 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 28 | { 29 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 30 | } 31 | 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 36 | if (-not($storageAccountSinkEnv)) 37 | { 38 | $storageAccountSinkEnv = $cloudEnvironment 39 | } 40 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 41 | $storageAccountSinkKey = $null 42 | if ($storageAccountSinkKeyCred) 43 | { 44 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 45 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 46 | } 47 | 48 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGAppGatewayContainer" -ErrorAction SilentlyContinue 49 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 50 | { 51 | $storageAccountSinkContainer = "argappgwexports" 52 | } 53 | 54 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 55 | { 56 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 57 | } 58 | 59 | $ARGPageSize = 1000 60 | 61 | "Logging in to Azure with $authenticationOption..." 62 | 63 | switch ($authenticationOption) { 64 | "UserAssignedManagedIdentity" { 65 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 66 | break 67 | } 68 | Default { #ManagedIdentity 69 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 70 | break 71 | } 72 | } 73 | 74 | if (-not($storageAccountSinkKey)) 75 | { 76 | Write-Output "Getting Storage Account context with login" 77 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 78 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 79 | } 80 | else 81 | { 82 | Write-Output "Getting Storage Account context with key" 83 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 84 | } 85 | 86 | $cloudSuffix = "" 87 | 88 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 89 | { 90 | "Logging in to Azure with $externalCredentialName external credential..." 91 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 92 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 93 | $cloudEnvironment = $externalCloudEnvironment 94 | } 95 | 96 | $tenantId = (Get-AzContext).Tenant.Id 97 | 98 | $allAppGWs = @() 99 | 100 | Write-Output "Getting subscriptions target $TargetSubscription" 101 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 102 | { 103 | $subscriptions = $TargetSubscription 104 | $subscriptionSuffix = $TargetSubscription 105 | } 106 | else 107 | { 108 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 109 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 110 | } 111 | 112 | $appGWsTotal = @() 113 | $resultsSoFar = 0 114 | 115 | Write-Output "Querying for Application Gateways properties" 116 | 117 | $argQuery = @" 118 | resources 119 | | where type =~ 'Microsoft.Network/applicationGateways' 120 | | extend gatewayIPsCount = array_length(properties.gatewayIPConfigurations) 121 | | extend frontendIPsCount = array_length(properties.frontendIPConfigurations) 122 | | extend frontendPortsCount = array_length(properties.frontendPorts) 123 | | extend backendPoolsCount = array_length(properties.backendAddressPools) 124 | | extend httpSettingsCount = array_length(properties.backendHttpSettingsCollection) 125 | | extend httpListenersCount = array_length(properties.httpListeners) 126 | | extend urlPathMapsCount = array_length(properties.urlPathMaps) 127 | | extend requestRoutingRulesCount = array_length(properties.requestRoutingRules) 128 | | extend probesCount = array_length(properties.probes) 129 | | extend rewriteRulesCount = array_length(properties.rewriteRuleSets) 130 | | extend redirectConfsCount = array_length(properties.redirectConfigurations) 131 | | project id, name, resourceGroup, subscriptionId, tenantId, location, zones, skuName = properties.sku.name, skuTier = properties.sku.tier, skuCapacity = properties.sku.capacity, enableHttp2 = properties.enableHttp2, gatewayIPsCount, frontendIPsCount, frontendPortsCount, httpSettingsCount, httpListenersCount, backendPoolsCount, urlPathMapsCount, requestRoutingRulesCount, probesCount, rewriteRulesCount, redirectConfsCount, tags 132 | | join kind=leftouter ( 133 | resources 134 | | where type =~ 'Microsoft.Network/applicationGateways' 135 | | mvexpand backendPools = properties.backendAddressPools 136 | | extend backendIPCount = array_length(backendPools.properties.backendIPConfigurations) 137 | | extend backendAddressesCount = array_length(backendPools.properties.backendAddresses) 138 | | summarize backendIPCount = sum(backendIPCount), backendAddressesCount = sum(backendAddressesCount) by id 139 | ) on id 140 | | project-away id1 141 | | order by id asc 142 | "@ 143 | 144 | do 145 | { 146 | if ($resultsSoFar -eq 0) 147 | { 148 | $appGWs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 149 | } 150 | else 151 | { 152 | $appGWs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 153 | } 154 | if ($appGWs -and $appGWs.GetType().Name -eq "PSResourceGraphResponse") 155 | { 156 | $appGWs = $appGWs.Data 157 | } 158 | $resultsCount = $appGWs.Count 159 | $resultsSoFar += $resultsCount 160 | $appGWsTotal += $appGWs 161 | 162 | } while ($resultsCount -eq $ARGPageSize) 163 | 164 | Write-Output "Found $($appGWsTotal.Count) Application Gateway entries" 165 | 166 | <# 167 | Building CSV entries 168 | #> 169 | 170 | $datetime = (get-date).ToUniversalTime() 171 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 172 | $statusDate = $datetime.ToString("yyyy-MM-dd") 173 | 174 | foreach ($appGW in $appGWsTotal) 175 | { 176 | $logentry = New-Object PSObject -Property @{ 177 | Timestamp = $timestamp 178 | Cloud = $cloudEnvironment 179 | TenantGuid = $appGW.tenantId 180 | SubscriptionGuid = $appGW.subscriptionId 181 | ResourceGroupName = $appGW.resourceGroup.ToLower() 182 | InstanceName = $appGW.name.ToLower() 183 | InstanceId = $appGW.id.ToLower() 184 | SkuName = $appGW.skuName 185 | SkuTier = $appGW.skuTier 186 | SkuCapacity = $appGW.skuCapacity 187 | Location = $appGW.location 188 | Zones = $appGW.zones 189 | EnableHttp2 = $appGW.enableHttp2 190 | GatewayIPsCount = $appGW.gatewayIPsCount 191 | FrontendIPsCount = $appGW.frontendIPsCount 192 | FrontendPortsCount = $appGW.frontendPortsCount 193 | BackendIPCount = $appGW.backendIPCount 194 | BackendAddressesCount = $appGW.backendAddressesCount 195 | HttpSettingsCount = $appGW.httpSettingsCount 196 | HttpListenersCount = $appGW.httpListenersCount 197 | BackendPoolsCount = $appGW.backendPoolsCount 198 | ProbesCount = $appGW.probesCount 199 | UrlPathMapsCount = $appGW.urlPathMapsCount 200 | RequestRoutingRulesCount = $appGW.requestRoutingRulesCount 201 | RewriteRulesCount = $appGW.rewriteRulesCount 202 | RedirectConfsCount = $appGW.redirectConfsCount 203 | StatusDate = $statusDate 204 | Tags = $appGW.tags 205 | } 206 | 207 | $allAppGWs += $logentry 208 | } 209 | 210 | <# 211 | Actually exporting CSV to Azure Storage 212 | #> 213 | 214 | $today = $datetime.ToString("yyyyMMdd") 215 | $csvExportPath = "$today-appgws-$subscriptionSuffix.csv" 216 | 217 | $allAppGWs | Export-Csv -Path $csvExportPath -NoTypeInformation 218 | 219 | $csvBlobName = $csvExportPath 220 | 221 | $csvProperties = @{"ContentType" = "text/csv"}; 222 | 223 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 224 | 225 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 226 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 227 | 228 | Remove-Item -Path $csvExportPath -Force 229 | 230 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 231 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/data-collection/Export-ARGNICPropertiesToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $TargetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $referenceRegion = Get-AutomationVariable -Name "AzureOptimization_ReferenceRegion" -ErrorAction SilentlyContinue # e.g., westeurope 23 | if ([string]::IsNullOrEmpty($referenceRegion)) 24 | { 25 | $referenceRegion = "westeurope" 26 | } 27 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 28 | if ([string]::IsNullOrEmpty($authenticationOption)) 29 | { 30 | $authenticationOption = "ManagedIdentity" 31 | } 32 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 33 | { 34 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 35 | } 36 | 37 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 38 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 39 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 40 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 41 | if (-not($storageAccountSinkEnv)) 42 | { 43 | $storageAccountSinkEnv = $cloudEnvironment 44 | } 45 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 46 | $storageAccountSinkKey = $null 47 | if ($storageAccountSinkKeyCred) 48 | { 49 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 50 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 51 | } 52 | 53 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_ARGNICContainer" -ErrorAction SilentlyContinue 54 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 55 | { 56 | $storageAccountSinkContainer = "argnicexports" 57 | } 58 | 59 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 60 | { 61 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 62 | } 63 | 64 | $ARGPageSize = 1000 65 | 66 | "Logging in to Azure with $authenticationOption..." 67 | 68 | switch ($authenticationOption) { 69 | "UserAssignedManagedIdentity" { 70 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 71 | break 72 | } 73 | Default { #ManagedIdentity 74 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 75 | break 76 | } 77 | } 78 | 79 | if (-not($storageAccountSinkKey)) 80 | { 81 | Write-Output "Getting Storage Account context with login" 82 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 83 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 84 | } 85 | else 86 | { 87 | Write-Output "Getting Storage Account context with key" 88 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 89 | } 90 | 91 | $cloudSuffix = "" 92 | 93 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 94 | { 95 | "Logging in to Azure with $externalCredentialName external credential..." 96 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 97 | $cloudSuffix = $externalCloudEnvironment.ToLower() + "-" 98 | $cloudEnvironment = $externalCloudEnvironment 99 | } 100 | 101 | $tenantId = (Get-AzContext).Tenant.Id 102 | 103 | $allnics = @() 104 | 105 | Write-Output "Getting subscriptions target $TargetSubscription" 106 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 107 | { 108 | $subscriptions = $TargetSubscription 109 | $subscriptionSuffix = $TargetSubscription 110 | } 111 | else 112 | { 113 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" } | ForEach-Object { "$($_.Id)"} 114 | $subscriptionSuffix = $cloudSuffix + "all-" + $tenantId 115 | } 116 | 117 | $nicsTotal = @() 118 | 119 | $resultsSoFar = 0 120 | 121 | Write-Output "Querying for NIC properties" 122 | 123 | $argQuery = @" 124 | resources 125 | | where type =~ 'microsoft.network/networkinterfaces' 126 | | extend isPrimary = properties.primary 127 | | extend enableAcceleratedNetworking = properties.enableAcceleratedNetworking 128 | | extend enableIPForwarding = properties.enableIPForwarding 129 | | extend tapConfigurationsCount = array_length(properties.tapConfigurations) 130 | | extend hostedWorkloadsCount = array_length(properties.hostedWorkloads) 131 | | extend internalDomainNameSuffix = properties.dnsSettings.internalDomainNameSuffix 132 | | extend appliedDnsServers = properties.dnsSettings.appliedDnsServers 133 | | extend dnsServers = properties.dnsSettings.dnsServers 134 | | extend ownerVMId = tolower(properties.virtualMachine.id) 135 | | extend ownerPEId = tolower(properties.privateEndpoint.id) 136 | | extend macAddress = properties.macAddress 137 | | extend nicType = properties.nicType 138 | | extend nicNsgId = tolower(properties.networkSecurityGroup.id) 139 | | mv-expand ipconfigs = properties.ipConfigurations 140 | | project-away properties 141 | | extend privateIPAddressVersion = tostring(ipconfigs.properties.privateIPAddressVersion) 142 | | extend privateIPAllocationMethod = tostring(ipconfigs.properties.privateIPAllocationMethod) 143 | | extend isIPConfigPrimary = tostring(ipconfigs.properties.primary) 144 | | extend privateIPAddress = tostring(ipconfigs.properties.privateIPAddress) 145 | | extend publicIPId = tolower(ipconfigs.properties.publicIPAddress.id) 146 | | extend IPConfigName = tostring(ipconfigs.name) 147 | | extend subnetId = tolower(ipconfigs.properties.subnet.id) 148 | | project-away ipconfigs 149 | | order by id asc 150 | "@ 151 | 152 | do 153 | { 154 | if ($resultsSoFar -eq 0) 155 | { 156 | $nics = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 157 | } 158 | else 159 | { 160 | $nics = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 161 | } 162 | if ($nics -and $nics.GetType().Name -eq "PSResourceGraphResponse") 163 | { 164 | $nics = $nics.Data 165 | } 166 | $resultsCount = $nics.Count 167 | $resultsSoFar += $resultsCount 168 | $nicsTotal += $nics 169 | 170 | } while ($resultsCount -eq $ARGPageSize) 171 | 172 | $datetime = (Get-Date).ToUniversalTime() 173 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 174 | $statusDate = $datetime.ToString("yyyy-MM-dd") 175 | 176 | Write-Output "Building $($nicsTotal.Count) ARM VNet nic entries" 177 | 178 | foreach ($nic in $nicsTotal) 179 | { 180 | $logentry = New-Object PSObject -Property @{ 181 | Timestamp = $timestamp 182 | Cloud = $cloudEnvironment 183 | TenantGuid = $nic.tenantId 184 | SubscriptionGuid = $nic.subscriptionId 185 | ResourceGroupName = $nic.resourceGroup.ToLower() 186 | Location = $nic.location 187 | Name = $nic.name.ToLower() 188 | InstanceId = $nic.id.ToLower() 189 | IsPrimary = $nic.isPrimary 190 | EnableAcceleratedNetworking = $nic.enableAcceleratedNetworking 191 | EnableIPForwarding = $nic.enableIPForwarding 192 | TapConfigurationsCount = $nic.tapConfigurationsCount 193 | HostedWorkloadsCount = $nic.hostedWorkloadsCount 194 | InternalDomainNameSuffix = $nic.internalDomainNameSuffix 195 | AppliedDnsServers = $nic.appliedDnsServers 196 | DnsServers = $nic.dnsServers 197 | OwnerVMId = $nic.ownerVMId 198 | OwnerPEId = $nic.ownerPEId 199 | MacAddress = $nic.macAddress 200 | NicType = $nic.nicType 201 | NicNSGId = $nic.nicNsgId 202 | PrivateIPAddressVersion = $nic.privateIPAddressVersion 203 | PrivateIPAllocationMethod = $nic.privateIPAllocationMethod 204 | IsIPConfigPrimary = $nic.isIPConfigPrimary 205 | PrivateIPAddress = $nic.privateIPAddress 206 | PublicIPId = $nic.publicIPId 207 | IPConfigName = $nic.IPConfigName 208 | SubnetId = $nic.subnetId 209 | Tags = $nic.tags 210 | StatusDate = $statusDate 211 | } 212 | 213 | $allnics += $logentry 214 | } 215 | 216 | Write-Output "Uploading CSV to Storage" 217 | 218 | $today = $datetime.ToString("yyyyMMdd") 219 | $csvExportPath = "$today-nics-$subscriptionSuffix.csv" 220 | 221 | $allnics | Export-Csv -Path $csvExportPath -NoTypeInformation 222 | 223 | $csvBlobName = $csvExportPath 224 | 225 | $csvProperties = @{"ContentType" = "text/csv"}; 226 | 227 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 228 | 229 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 230 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 231 | 232 | Remove-Item -Path $csvExportPath -Force 233 | 234 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 235 | Write-Output "[$now] Removed $csvExportPath from local disk..." -------------------------------------------------------------------------------- /runbooks/data-collection/Export-AdvisorRecommendationsToBlobStorage.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $false)] 3 | [string] $targetSubscription, 4 | 5 | [Parameter(Mandatory = $false)] 6 | [string] $externalCloudEnvironment, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string] $externalTenantId, 10 | 11 | [Parameter(Mandatory = $false)] 12 | [string] $externalCredentialName 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | $cloudEnvironment = Get-AutomationVariable -Name "AzureOptimization_CloudEnvironment" -ErrorAction SilentlyContinue # AzureCloud|AzureChinaCloud 18 | if ([string]::IsNullOrEmpty($cloudEnvironment)) 19 | { 20 | $cloudEnvironment = "AzureCloud" 21 | } 22 | $authenticationOption = Get-AutomationVariable -Name "AzureOptimization_AuthenticationOption" -ErrorAction SilentlyContinue # ManagedIdentity|UserAssignedManagedIdentity 23 | if ([string]::IsNullOrEmpty($authenticationOption)) 24 | { 25 | $authenticationOption = "ManagedIdentity" 26 | } 27 | if ($authenticationOption -eq "UserAssignedManagedIdentity") 28 | { 29 | $uamiClientID = Get-AutomationVariable -Name "AzureOptimization_UAMIClientID" 30 | } 31 | 32 | $storageAccountSink = Get-AutomationVariable -Name "AzureOptimization_StorageSink" 33 | $storageAccountSinkRG = Get-AutomationVariable -Name "AzureOptimization_StorageSinkRG" 34 | $storageAccountSinkSubscriptionId = Get-AutomationVariable -Name "AzureOptimization_StorageSinkSubId" 35 | $storageAccountSinkEnv = Get-AutomationVariable -Name "AzureOptimization_StorageSinkEnvironment" -ErrorAction SilentlyContinue 36 | if (-not($storageAccountSinkEnv)) 37 | { 38 | $storageAccountSinkEnv = $cloudEnvironment 39 | } 40 | $storageAccountSinkKeyCred = Get-AutomationPSCredential -Name "AzureOptimization_StorageSinkKey" -ErrorAction SilentlyContinue 41 | $storageAccountSinkKey = $null 42 | if ($storageAccountSinkKeyCred) 43 | { 44 | $storageAccountSink = $storageAccountSinkKeyCred.UserName 45 | $storageAccountSinkKey = $storageAccountSinkKeyCred.GetNetworkCredential().Password 46 | } 47 | 48 | $storageAccountSinkContainer = Get-AutomationVariable -Name "AzureOptimization_AdvisorContainer" -ErrorAction SilentlyContinue 49 | if ([string]::IsNullOrEmpty($storageAccountSinkContainer)) 50 | { 51 | $storageAccountSinkContainer = "advisorexports" 52 | } 53 | 54 | $CategoryFilter = Get-AutomationVariable -Name "AzureOptimization_AdvisorFilter" -ErrorAction SilentlyContinue 55 | if ([string]::IsNullOrEmpty($CategoryFilter)) 56 | { 57 | $CategoryFilter = "HighAvailability,Security,Performance,OperationalExcellence" # comma-separated list of categories 58 | } 59 | $CategoryFilter += ",Cost" 60 | 61 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 62 | { 63 | $externalCredential = Get-AutomationPSCredential -Name $externalCredentialName 64 | } 65 | 66 | "Logging in to Azure with $authenticationOption..." 67 | 68 | switch ($authenticationOption) { 69 | "UserAssignedManagedIdentity" { 70 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment -AccountId $uamiClientID 71 | break 72 | } 73 | Default { #ManagedIdentity 74 | Connect-AzAccount -Identity -EnvironmentName $cloudEnvironment 75 | break 76 | } 77 | } 78 | 79 | if (-not($storageAccountSinkKey)) 80 | { 81 | Write-Output "Getting Storage Account context with login" 82 | Select-AzSubscription -SubscriptionId $storageAccountSinkSubscriptionId 83 | $saCtx = (Get-AzStorageAccount -ResourceGroupName $storageAccountSinkRG -Name $storageAccountSink).Context 84 | } 85 | else 86 | { 87 | Write-Output "Getting Storage Account context with key" 88 | $saCtx = New-AzStorageContext -StorageAccountName $storageAccountSink -StorageAccountKey $storageAccountSinkKey -Environment $storageAccountSinkEnv 89 | } 90 | 91 | if (-not([string]::IsNullOrEmpty($externalCredentialName))) 92 | { 93 | "Logging in to Azure with $externalCredentialName external credential..." 94 | Connect-AzAccount -ServicePrincipal -EnvironmentName $externalCloudEnvironment -Tenant $externalTenantId -Credential $externalCredential 95 | $cloudEnvironment = $externalCloudEnvironment 96 | } 97 | 98 | Write-Output "Getting subscriptions target $TargetSubscription" 99 | 100 | $tenantId = (Get-AzContext).Tenant.Id 101 | 102 | $ARGPageSize = 1000 103 | 104 | if (-not([string]::IsNullOrEmpty($TargetSubscription))) 105 | { 106 | $subscriptions = $TargetSubscription 107 | $scope = $TargetSubscription 108 | } 109 | else 110 | { 111 | $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" -and $_.SubscriptionPolicies.QuotaId -notlike "AAD*" } | ForEach-Object { "$($_.Id)"} 112 | $scope = $tenantId 113 | } 114 | 115 | 116 | <# 117 | Getting Advisor recommendations for each subscription and building CSV entries 118 | #> 119 | 120 | $datetime = (get-date).ToUniversalTime() 121 | $timestamp = $datetime.ToString("yyyy-MM-ddTHH:mm:00.000Z") 122 | 123 | $recommendationsARG = @() 124 | 125 | $resultsSoFar = 0 126 | 127 | $FinalCategoryFilter = "" 128 | 129 | if (-not([string]::IsNullOrEmpty($CategoryFilter))) 130 | { 131 | $categories = $CategoryFilter.Split(',') 132 | for ($i = 0; $i -lt $categories.Count; $i++) 133 | { 134 | $categories[$i] = "'" + $categories[$i] + "'" 135 | } 136 | $FinalCategoryFilter = " and properties.category in (" + ($categories -join ",") + ")" 137 | } 138 | 139 | $argQuery = @" 140 | advisorresources 141 | | where type == 'microsoft.advisor/recommendations' 142 | | where isnull(properties.suppressionIds)$FinalCategoryFilter 143 | | extend resourceId = tostring(split(tolower(id),'/providers/microsoft.advisor')[0]) 144 | | join kind=leftouter (resources | project resourceId=tolower(id), resourceTags=tags) on resourceId 145 | | project id, category = properties.category, impact = properties.impact, impactedArea = properties.impactedField, 146 | description = properties.shortDescription.problem, recommendationText = properties.shortDescription.solution, 147 | recommendationTypeId = properties.recommendationTypeId, instanceName = properties.impactedValue, 148 | additionalInfo = properties.extendedProperties, tags=resourceTags 149 | | order by id asc 150 | "@ 151 | 152 | do 153 | { 154 | if ($resultsSoFar -eq 0) 155 | { 156 | $recs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Subscription $subscriptions 157 | } 158 | else 159 | { 160 | $recs = Search-AzGraph -Query $argQuery -First $ARGPageSize -Skip $resultsSoFar -Subscription $subscriptions 161 | } 162 | if ($recs -and $recs.GetType().Name -eq "PSResourceGraphResponse") 163 | { 164 | $recs = $recs.Data 165 | } 166 | $resultsCount = $recs.Count 167 | $resultsSoFar += $resultsCount 168 | $recommendationsARG += $recs 169 | 170 | } while ($resultsCount -eq $ARGPageSize) 171 | 172 | Write-Output "Building $($recommendationsARG.Count) recommendations entries" 173 | 174 | $recommendations = @() 175 | 176 | foreach ($advisorRecommendation in $recommendationsARG) 177 | { 178 | $resourceIdParts = $advisorRecommendation.id.Split('/') 179 | if ($resourceIdParts.Count -ge 9) 180 | { 181 | # if the Resource ID is made of 9 parts, then the recommendation is relative to a specific Azure resource 182 | $realResourceIdParts = $resourceIdParts[0..8] 183 | $instanceId = ($realResourceIdParts -join "/").ToLower() 184 | $resourceGroup = $realResourceIdParts[4].ToLower() 185 | $subscriptionId = $realResourceIdParts[2] 186 | } 187 | else 188 | { 189 | # otherwise it is not a resource-specific recommendation (e.g., reservations) 190 | $resourceGroup = "notavailable" 191 | $instanceId = $advisorRecommendation.id.ToLower() 192 | $subscriptionId = $resourceIdParts[2] 193 | } 194 | 195 | if (-not([string]::IsNullOrEmpty($advisorRecommendation.additionalInfo))) 196 | { 197 | $additionalInfo = $advisorRecommendation.additionalInfo | ConvertTo-Json -Compress 198 | } 199 | else 200 | { 201 | $additionalInfo = $null 202 | } 203 | 204 | $recommendation = New-Object PSObject -Property @{ 205 | Timestamp = $timestamp 206 | Cloud = $cloudEnvironment 207 | Category = $advisorRecommendation.category 208 | Impact = $advisorRecommendation.impact 209 | ImpactedArea = $advisorRecommendation.impactedArea 210 | Description = $advisorRecommendation.description 211 | RecommendationText = $advisorRecommendation.recommendationText 212 | RecommendationTypeId = $advisorRecommendation.recommendationTypeId 213 | InstanceId = $instanceId 214 | InstanceName = $advisorRecommendation.instanceName 215 | Tags = $advisorRecommendation.tags 216 | AdditionalInfo = $additionalInfo 217 | ResourceGroup = $resourceGroup 218 | SubscriptionGuid = $subscriptionId 219 | TenantGuid = $tenantId 220 | } 221 | 222 | $recommendations += $recommendation 223 | } 224 | 225 | Write-Output "Found $($recommendations.Count) ($CategoryFilter) recommendations..." 226 | 227 | $fileDate = $datetime.ToString("yyyyMMdd") 228 | $advisorFilter = $CategoryFilter.Replace(',','').ToLower() 229 | $csvExportPath = "$fileDate-$advisorFilter-$scope.csv" 230 | 231 | $recommendations | Export-Csv -NoTypeInformation -Path $csvExportPath 232 | Write-Output "Export to $csvExportPath" 233 | 234 | $csvBlobName = $csvExportPath 235 | $csvProperties = @{"ContentType" = "text/csv"}; 236 | 237 | Set-AzStorageBlobContent -File $csvExportPath -Container $storageAccountSinkContainer -Properties $csvProperties -Blob $csvBlobName -Context $saCtx -Force 238 | 239 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 240 | Write-Output "[$now] Uploaded $csvBlobName to Blob Storage..." 241 | 242 | Remove-Item -Path $csvExportPath -Force 243 | 244 | $now = (Get-Date).ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'") 245 | Write-Output "[$now] Removed $csvExportPath from local disk..." 246 | 247 | Write-Output "DONE!" -------------------------------------------------------------------------------- /Suppress-Recommendation.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $true)] 3 | [String] $RecommendationId 4 | ) 5 | 6 | $ErrorActionPreference = "Stop" 7 | 8 | function Test-IsGuid 9 | { 10 | [OutputType([bool])] 11 | param 12 | ( 13 | [Parameter(Mandatory = $true)] 14 | [string]$ObjectGuid 15 | ) 16 | 17 | # Define verification regex 18 | [regex]$guidRegex = '(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$' 19 | 20 | # Check guid against regex 21 | return $ObjectGuid -match $guidRegex 22 | } 23 | 24 | if (-not(Test-IsGuid -ObjectGuid $RecommendationId)) 25 | { 26 | Write-Host "The provided recommendation Id is invalid. Must be a valid GUID." -ForegroundColor Red 27 | Exit 28 | } 29 | 30 | $databaseConnectionSettingsPath = ".\database-connection-settings.json" 31 | $dbConnectionSettings = @{} 32 | 33 | if (Test-Path -Path $databaseConnectionSettingsPath) 34 | { 35 | $dbSettings = Get-Content -Path $databaseConnectionSettingsPath | ConvertFrom-Json 36 | Write-Host $dbSettings -ForegroundColor Green 37 | $dbSettingsReuse = Read-Host "Found existing database connection settings. Do you want to reuse them (Y/N)?" 38 | if ("Y", "y" -contains $dbSettingsReuse) 39 | { 40 | foreach ($property in $dbSettings.PSObject.Properties) 41 | { 42 | $dbConnectionSettings[$property.Name] = $property.Value 43 | } 44 | } 45 | } 46 | 47 | if (-not($dbConnectionSettings["DatabaseServer"])) 48 | { 49 | $databaseServer = Read-Host "Please, enter the AOE Azure SQL server hostname (e.g., xpto.database.windows.net)" 50 | $dbConnectionSettings["DatabaseServer"] = $databaseServer 51 | } 52 | else 53 | { 54 | $databaseServer = $dbConnectionSettings["DatabaseServer"] 55 | } 56 | 57 | if (-not($dbConnectionSettings["DatabaseName"])) 58 | { 59 | $databaseName = Read-Host "Please, enter the AOE Azure SQL Database name (e.g., azureoptimization)" 60 | $dbConnectionSettings["DatabaseName"] = $databaseName 61 | } 62 | else 63 | { 64 | $databaseName = $dbConnectionSettings["DatabaseName"] 65 | } 66 | 67 | if (-not($dbConnectionSettings["DatabaseUser"])) 68 | { 69 | $databaseUser = Read-Host "Please, enter the AOE database user name" 70 | $dbConnectionSettings["DatabaseUser"] = $databaseUser 71 | } 72 | else 73 | { 74 | $databaseUser = $dbConnectionSettings["DatabaseUser"] 75 | } 76 | 77 | $sqlPass = Read-Host "Please, input the password for the $databaseUser SQL user" -AsSecureString 78 | $sqlPassPlain = (New-Object PSCredential "user", $sqlPass).GetNetworkCredential().Password 79 | $sqlPassPlain = $sqlPassPlain.Replace("'", "''") 80 | 81 | $SqlTimeout = 120 82 | $recommendationsTable = "Recommendations" 83 | $suppressionsTable = "Filters" 84 | 85 | Write-Host "Opening connection to the database..." -ForegroundColor Green 86 | 87 | $tries = 0 88 | $connectionSuccess = $false 89 | do { 90 | $tries++ 91 | try { 92 | $Conn = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$databaseServer,1433;Database=$databaseName;User ID=$databaseUser;Password='$sqlPassPlain';Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") 93 | $Conn.Open() 94 | $Cmd=new-object system.Data.SqlClient.SqlCommand 95 | $Cmd.Connection = $Conn 96 | $Cmd.CommandTimeout = $SqlTimeout 97 | $Cmd.CommandText = "SELECT * FROM [dbo].[$recommendationsTable] WHERE RecommendationId = '$RecommendationId'" 98 | 99 | $sqlAdapter = New-Object System.Data.SqlClient.SqlDataAdapter 100 | $sqlAdapter.SelectCommand = $Cmd 101 | $controlRows = New-Object System.Data.DataTable 102 | $sqlAdapter.Fill($controlRows) | Out-Null 103 | $connectionSuccess = $true 104 | } 105 | catch { 106 | Write-Host "Failed to contact SQL at try $tries." -ForegroundColor Yellow 107 | Write-Host $Error[0] -ForegroundColor Yellow 108 | Write-Output "Waiting $($tries * 20) seconds..." 109 | Start-Sleep -Seconds ($tries * 20) 110 | } 111 | } while (-not($connectionSuccess) -and $tries -lt 3) 112 | 113 | if (-not($connectionSuccess)) 114 | { 115 | throw "Could not establish connection to SQL." 116 | } 117 | 118 | $Conn.Close() 119 | $Conn.Dispose() 120 | 121 | if (-not($controlRows.RecommendationId)) 122 | { 123 | Write-Host "The provided recommendation Id was not found. Please, try again with a valid GUID." -ForegroundColor Red 124 | Exit 125 | } 126 | 127 | Write-Host "You are suppressing the recommendation with the below details" -ForegroundColor Green 128 | Write-Host "Recommendation: $($controlRows.RecommendationDescription)" -ForegroundColor Blue 129 | Write-Host "Recommendation sub-type id: $($controlRows.RecommendationSubTypeId)" -ForegroundColor Blue 130 | Write-Host "Category: $($controlRows.Category)" -ForegroundColor Blue 131 | Write-Host "Instance Name: $($controlRows.InstanceName)" -ForegroundColor Blue 132 | Write-Host "Resource Group: $($controlRows.ResourceGroup)" -ForegroundColor Blue 133 | Write-Host "Subscription Id: $($controlRows.SubscriptionGuid)" -ForegroundColor Blue 134 | Write-Host "Please, choose the suppression type" -ForegroundColor Green 135 | Write-Host "[E]xclude - this recommendation type will be completely excluded from the engine and will no longer be generated for any resource" -ForegroundColor Green 136 | Write-Host "[D]ismiss - this recommendation will be dismissed for the scope to be chosen next (instance, resource group or subscription)" -ForegroundColor Green 137 | Write-Host "[S]nooze - this recommendation will be postponed for the duration (in days) and scope to be chosen next (instance, resource group or subscription)" -ForegroundColor Green 138 | Write-Host "[C]ancel - no action will be taken" -ForegroundColor Green 139 | $suppOption = Read-Host "Enter your choice (E, D, S or C)" 140 | 141 | if ("E", "e" -contains $suppOption) 142 | { 143 | $suppressionType = "Exclude" 144 | } 145 | elseif ("D", "d" -contains $suppOption) 146 | { 147 | $suppressionType = "Dismiss" 148 | } 149 | elseif ("S", "s" -contains $suppOption) 150 | { 151 | $suppressionType = "Snooze" 152 | } 153 | else 154 | { 155 | Write-Host "Cancelling.. No action will be taken." -ForegroundColor Green 156 | Exit 157 | } 158 | 159 | if ($suppressionType -in ("Dismiss", "Snooze")) 160 | { 161 | Write-Host "Please, choose the scope for the suppression" -ForegroundColor Green 162 | Write-Host "[S]ubscription ($($controlRows.SubscriptionGuid))" -ForegroundColor Green 163 | Write-Host "[R]esource Group ($($controlRows.ResourceGroup))" -ForegroundColor Green 164 | Write-Host "[I]nstance ($($controlRows.InstanceName))" -ForegroundColor Green 165 | $scopeOption = Read-Host "Enter your choice (S, R, or I)" 166 | 167 | if ("S", "s" -contains $scopeOption) 168 | { 169 | $scope = $controlRows.SubscriptionGuid 170 | } 171 | elseif ("R", "r" -contains $scopeOption) 172 | { 173 | $scope = $controlRows.ResourceGroup 174 | } 175 | elseif ("I", "i" -contains $scopeOption) 176 | { 177 | $scope = $controlRows.InstanceId 178 | } 179 | else 180 | { 181 | Write-Host "Wrong input. No action will be taken." -ForegroundColor Red 182 | Exit 183 | } 184 | } 185 | 186 | $snoozeDays = 0 187 | if ($suppressionType -eq "Snooze") 188 | { 189 | Write-Host "Please, enter the number of days the recommendation will be snoozed" -ForegroundColor Green 190 | $snoozeDays = Read-Host "Number of days (min. 14)" 191 | if (-not($snoozeDays -ge 14)) 192 | { 193 | Write-Host "Wrong snooze days. No action will be taken." -ForegroundColor Red 194 | Exit 195 | } 196 | } 197 | 198 | $author = Read-Host "Please enter your name" 199 | $notes = Read-Host "Please enter a reason for this suppression" 200 | 201 | Write-Host "You are about to suppress this recommendation" -ForegroundColor Yellow 202 | Write-Host "Recommendation: $($controlRows.RecommendationDescription)" -ForegroundColor Blue 203 | Write-Host "Suppression type: $suppressionType" -ForegroundColor Blue 204 | if ($suppressionType -in ("Dismiss", "Snooze")) 205 | { 206 | Write-Host "Scope: $scope" -ForegroundColor Blue 207 | } 208 | if ($suppressionType -eq "Snooze") 209 | { 210 | Write-Host "Snooze days: $snoozeDays" -ForegroundColor Blue 211 | } 212 | Write-Host "Author: $author" -ForegroundColor Blue 213 | Write-Host "Reason: $notes" -ForegroundColor Blue 214 | $continueInput = Read-Host "Do you want to continue (Y/N)?" 215 | if ("Y", "y" -contains $continueInput) 216 | { 217 | if ($scope) 218 | { 219 | $scope = "'$scope'" 220 | } 221 | else 222 | { 223 | $scope = "NULL" 224 | } 225 | 226 | if ($snoozeDays -ge 14) 227 | { 228 | $now = (Get-Date).ToUniversalTime() 229 | $endDate = "'$($now.Add($snoozeDays).ToString("yyyy-MM-ddTHH:mm:00Z"))'" 230 | } 231 | else { 232 | $endDate = "NULL" 233 | } 234 | 235 | $sqlStatement = "INSERT INTO [$suppressionsTable] VALUES (NEWID(), '$($controlRows.RecommendationSubTypeId)', '$suppressionType', $scope, GETDATE(), $endDate, '$author', '$notes', 1)" 236 | 237 | $Conn2 = New-Object System.Data.SqlClient.SqlConnection("Server=tcp:$databaseServer,1433;Database=$databaseName;User ID=$databaseUser;Password='$sqlPassPlain';Trusted_Connection=False;Encrypt=True;Connection Timeout=$SqlTimeout;") 238 | $Conn2.Open() 239 | 240 | $Cmd=new-object system.Data.SqlClient.SqlCommand 241 | $Cmd.Connection = $Conn2 242 | $Cmd.CommandText = $sqlStatement 243 | $Cmd.CommandTimeout=120 244 | try 245 | { 246 | $Cmd.ExecuteReader() 247 | } 248 | catch 249 | { 250 | Write-Output "Failed statement: $sqlStatement" 251 | throw 252 | } 253 | 254 | $Conn2.Close() 255 | 256 | Write-Host "Suppression sucessfully added." -ForegroundColor Green 257 | } 258 | else 259 | { 260 | Write-Host "No action was taken." -ForegroundColor Green 261 | } 262 | 263 | $dbConnectionSettings | ConvertTo-Json | Out-File -FilePath $databaseConnectionSettingsPath -Force --------------------------------------------------------------------------------