<# .SYNOPSIS Clean up unused resources in subscriptions. .DESCRIPTION This script removes unused Azure resources in a subscription. The target resource to remove are divided into three types as following: 1. Storage 2. Network 3. ResourceGroup Azure resources managed by each type are as follows. [Storage] - (VHD) Blobs - Managed disk - Storage account [Network] - Network interface - Public IP address - Network security group - Virtual network [ResourceGroup] - Resource group .PARAMETER SubscriptionNameOrId Indicates the name or ID of a subscription to select. If passing this parameter, it's necessary that the subscription can be uniquely identified by the name or ID. Otherwise, a dialog to select a subscription is displayed. .PARAMETER RemoveStorage .PARAMETER RemoveNetwork .PARAMETER RemoveResourceGroup These switch parameters indicate which resource type you want to remove, and can be used in combination. .PARAMETER RemoveAll Turning this switch parameter on means that all resource types will be targeted to remove, that is, parameters RemoveStorage, RemoveNetwork and RemoveResourceGroup will be enabled. .EXAMPLE C:\PS>CleanupUnusedResource.ps1 -RemoveAll .NOTES Author: Junya Yamaguchi (t-juyama) Date: September 27, 2017 #> Param ( [Parameter(Mandatory=$false)] [string] $SubscriptionNameOrId, [Parameter(Mandatory=$false)] [switch] $RemoveStorage, [Parameter(Mandatory=$false)] [switch] $RemoveNetwork, [Parameter(Mandatory=$false)] [switch] $RemoveResourceGroup, [Parameter(Mandatory=$false)] [switch] $RemoveAll ) # Switch parameter to decide parmanently deletion. $ProductionRun = $false # Simple function to write a section header. Function Write-SectionTitle ($Title) { Write-Output "" Write-Output ("=" * 80) Write-Output "| $Title" Write-Output ("=" * 80) } Function Write-SubsectionTitle ($Title) { Write-Output "" Write-Output "# $Title" Write-Output ("-" * ("# $Title ".Length)) } # ============================================================================== # Functions related to storage resources # ============================================================================== Function Get-BootDiagnosedVmName { <# .SYNOPSIS Get the name of a VM which boot diagnostics files are stored in the given storage container. .DESCRIPTION This function returns the name of a VM which boot diagnostics files are stored in the given storage container if so. Otherwise, it throws an exception with a massage. .PARAMETER StorageContainer Indicates a strorage container object, which can be created through functions related to storage containers, like Get-AzureStorageContainer. #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline=$true, Mandatory=$true)] [object] $StorageContainer ) process { $Segments = $StorageContainer.Name.Split("-") # Check if the container name looks like boot diagnostics one. if ($Segments.Count -ne 7 -or $Segments[0] -ne "bootdiagnostics") { throw "The container name must look like `"bootdiagnostics-[vmname]-00000000-0000-0000-0000-000000000000`", but it's `"$($StorageContainer.Name)`"." } # Get the log id. $LogId = $Segments[2] foreach ($i in @(3,4,5,6)) { $LogId = $LogId + "-" + $Segments[$i] } # Check if the number of blob files is 2. $Blobs = $StorageContainer | Get-AzureStorageBlob if ($Blobs.Count -lt 2) { throw "Though the container name seems to be for boot-diagnostics, the number of blobs ($($Blobs.Count)) is less than two." } elseif ($Blobs.Count -gt 2) { throw "Though the container name seems to be for boot-diagnostics, the number of blobs ($($Blobs.Count)) is more than two." } # Check if the name of blob files ends with certain extantions. $DiagnosticBlobs = $Blobs | Select -ExpandProperty Name | Where { $_.EndsWith(".screenshot.bmp") -or ` $_.EndsWith(".serialconsole.log") } if ($DiagnosticBlobs.Count -ne 2) { throw "Though the container name and the number of the blobs seem to be for boot-diagnostics, there is a blog which name ends with an extension that is not used in boot diagnosis." } # Check all blobs in the container match the following regex format. $Regex = "^(.*)\." + $LogId + "\.[screenshot\.bmp|serialconsole\.log]" $VmNames = $DiagnosticBlobs | ForEach { if ($_ -match $Regex) { $Matches[1] } else { $null } } if ($VmNames -contains $null -or ($VmNames | Sort -Unique).Count -ne 1) { throw "The filenames of the blobs are not expected string." } # Output the vm name. Write-Output $VmNames[0] } } Function Is-UnusedStorageContainer { <# .SYNOPSIS Get the VM name which boot diagnostics files are stored in the given storage container. .DESCRIPTION This function returns the name of a VM which boot diagnostics files are stored in the given storage container if so. Otherwise, it throws an exception with a massage. .PARAMETER StorageContainer Indicates a strorage container object, which can be created through functions related to storage containers, like Get-AzureStorageContainer. #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline=$true, Mandatory=$true)] [object] $StorageContainer ) begin { $AllStorageAccounts = Get-AzureRmStorageAccount } process { # Get VM which boot diagnostic files are stored in the given container # if they are, null otherwise. try { $VmName = $StorageContainer | Get-BootDiagnosedVmName } catch { $VmName = $null } if ($VmName) { # Get storage accounts used by VMs with a certain name, where a VM # with the name was boot-diagnosed, storing its data into the given container. $CandidateVMs = Get-AzureRmVM | Where {$_.Name -eq $VmName} if ($CandidateVMs -eq $null) { Write-Output $true return } $BlobUris = $CandidateVMs | ForEach { $_.DiagnosticsProfile.BootDiagnostics.StorageUri } | Get-Unique | Where { $_ -ne $null } $AccountNames = $AllStorageAccounts | Where {$BlobUris -contains $_.PrimaryEndpoints.Blob} | Select -ExpandProperty StorageAccountName if ($AccountNames -contains $StorageContainer.Context.StorageAccountName) { Write-Output $false } else { Write-Output $true } } # In the case that the container isn'n for boot diagnosetics. # TODO: # There may be many cases which could indicate unused-containers, # like containers after stopping replication, etc. else { Write-Output $false } } } Function Is-UnusedStorageAccount { <# .SYNOPSIS Decide whether a storage account is unused or not. .DESCRIPTION We define a storage account is unused such that: 1) has no container or all containers of are unused; and 2) has no share or all shares of are unused; and 3) has no table or all tables of are unused; and 4) has no queue or all queues of are unused. If you want to know the definition of an unused container, refer to the description of Is-UnusedStorageContainer function. However, for now, all share/table/queue files are regarded as in used regardless of their state because we have no method to detect if they're unused. .PARAMETER StorageContainer Indicates a strorage container object. It can be created through functions related to storage containers, like Get-AzureStorageContainer. #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline=$true, Mandatory=$true)] [object] $StorageAccount ) process { # Check if all containers are unused. try { $Conatiners = $StorageAccount | Get-AzureStorageContainer -ErrorAction Stop foreach ($Entry in $Conatiners) { if (-not ($Entry | Is-UnusedStorageContainer)) { Write-Output $false return } } } catch {} # Check if all shares are unused. try { $Shares = $StorageAccount | Get-AzureStorageShare -ErrorAction Stop # TODO: Find a method determines used/unused. if ($Shares) { Write-Output $false return } } catch {} # Check if all tables are unused. try { $Tables = $StorageAccount | Get-AzureStorageTable -ErrorAction Stop # TODO: Find a method determines used/unused. if ($Tables) { Write-Output $false return } } catch {} # Check if all queues are unused. try { $Queues = $StorageAccount | Get-AzureStorageQueue -ErrorAction Stop # TODO: Find a method determines used/unused. if ($Queues) { Write-Output $false return } } catch {} Write-Output $true } } Function Remove-Resource ($Name, $Plural, $Resource, $RemoveFunc, $SummarizeFunc) { # Get resource groups that contain no Azure resource. if (!$Resource) { Write-Output "There exists no unused $Name." return } # Show the list of resources Write-Output "[UNUSED RESOURCE]" if ($Resource[0].Id) { Write-Output ($Resource.Id | ForEach {"- $_"}) } else { Write-Output ($Resource.ResourceId | ForEach {"- $_"}) } Write-Output "`nThere exist(s) $($Resource.Count) unused $Name($Plural) you'd better to remove." # Select resources to remove. $SelectedRes = $Resource | Out-GridView -PassThru -Title "There exist(s) unused $Name($Plural) you'd better to remove. Select $Name($Plural) to remove." if (!$SelectedRes) { Write-Output "No $Name was selected to remove." return } # Show the list of selected resources Write-Output "`n[SELECTED RESOURCE]" if ($SelectedRes[0].Id) { Write-Output ($SelectedRes.Id | ForEach {"- $_"}) } else { Write-Output ($SelectedRes.ResourceId | ForEach {"- $_"}) } Write-Output "" if ($SummarizeFunc) { & $SummarizeFunc $SelectedRes } Write-Output "The above $($SelectedRes.Count) $Name($Plural) will be removed..." if ($ProductionRun) { # Confirm before removal. $ButtonType = [System.Windows.MessageBoxButton]::YesNo $MessageboxTitle = "Remove Confirmation" $Messageboxbody = "$($SelectedRes.Count) $Name($Plural) will be removed. Are you sure that you want to permanently remove selected resource(s)?" $MessageIcon = [System.Windows.MessageBoxImage]::Warning $YesOrNo = [System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $messageicon) if ($YesOrNo -eq "Yes") { $SelectedRes | & $RemoveFunc -Force >null Write-Output "Removed!" } else { Write-Output "Canceled." } } else { Write-Output "!!! This is not in production run mode !!!" } } # ============================================================================== # Main process # ============================================================================== # Check parameters # -------------------------------------------------------------------- if ($RemoveAll) { $RemoveStorage = $true $RemoveNetwork = $true $RemoveResourceGroup = $true } if (!$RemoveStorage -and !$RemoveNetwork -and !$RemoveResourceGroup) { throw "Please pass one or more `"-Remove*`" parameter(s)." } # Log-in to Azure if needed # -------------------------------------------------------------------- if ([string]::IsNullOrEmpty($(Get-AzureRmContext).Account)) { Login-AzureRmAccount } # Select subscription # -------------------------------------------------------------------- if ($SubscriptionNameOrId) { $TargetSubscriptions = Get-AzureRmSubscription | Where { $_.Name -eq $SubscriptionNameOrId -or $_.Id -eq $SubscriptionNameOrId } if ($TargetSubscriptions.Count -eq 0) { throw "No accessible subscription found with name or ID [$SubscriptionNameOrId]. Check the parameters and ensure user is a co-administrator on the target subscription." } elseif ($TargetSubscriptions.Count -gt 1) { throw "More than one accessible subscriptions found with name or ID [$SubscriptionNameOrId]. Please ensure your subscription names are unique, or specify the ID instead." } } else { # Show subscription list. $TargetSubscriptions = Get-AzureRmSubscription | Out-GridView -PassThru -Title "Select subscription(s) to target." if ($TargetSubscriptions.Count -eq 0) { throw "Please select at least one subscription." } } # Remove specified resources for each subscription # -------------------------------------------------------------------- ForEach ($Subscription in $TargetSubscriptions) { Write-SectionTitle "Select Subscription" Select-AzureRmSubscription -SubscriptionId $Subscription.Id if ($RemoveStorage) { Write-SectionTitle "Storage Resource" # -------------------------------------------------------------------- Write-SubsectionTitle "VHD Blob" # -------------------------------------------------------------------- # Get all blobs which filename have the extension ".vhd". $AllVhdsContainers = Get-AzureRmStorageAccount | Get-AzureStorageContainer | Where { $_.Name -eq "vhds" } $AllVhdBlobs = $AllVhdsContainers | Get-AzureStorageBlob | Where { $_.Name.EndsWith('.vhd') } # Find unused vhd blobs, where a vhd blob is defined as unused iff its lease status/state is unlocked/available. $LostVhdBlobs = $AllVhdBlobs | Where { $_.ICloudBlob.Properties.LeaseStatus -eq "Unlocked" -and $_.ICloudBlob.Properties.LeaseState -eq "Available" } # Define a function summarizes vhd blobs. Function Summarize-VhdBlobs ($Res) { $TotalSize = (($Res.Length | Measure-Object -Sum).Sum / 1073741824).ToString("0.00") Write-Output "The total size of vhd blobs amount to $TotalSize [GB]." } Remove-Resource -Name "vhd blob" -Plural "s" ` -Resource $LostVhdBlobs ` -RemoveFunc Remove-AzureRmResourceGroup ` -SummarizeFunc Summarize-VhdBlobs # -------------------------------------------------------------------- Write-SubsectionTitle "Disk (Managed Disk)" # -------------------------------------------------------------------- # Find unused disks, where we define a disk is unused iff its owner is null. $LostDisks = Get-AzureRmDisk | Where { $_.OwnerId -eq $null } Function Summarize-Disk ($Res) { $TotalSize = ($Res.DiskSizeGB | Measure-Object -Sum).Sum.ToString("0.00") Write-Output "The total size of disks amount to $TotalSize [GB]." } Remove-Resource -Name "managed disk" -Plural "s" ` -Resource $LostDisks ` -RemoveFunc Remove-AzureRmDisk ` -SummarizeFunc Summarize-Disk # -------------------------------------------------------------------- Write-SubsectionTitle "Storage Account" # -------------------------------------------------------------------- # Find unused storage account. # See the function description if you want to know the definition of "unused". $LostStAccounts = Get-AzureRmStorageAccount | Where { $_ | Is-UnusedStorageAccount } Remove-Resource -Name "storage account" -Plural "s" ` -Resource $LostStAccounts ` -RemoveFunc Remove-AzureRmStorageAccount } if ($RemoveNetwork) { Write-SectionTitle "Network Resource" # -------------------------------------------------------------------- Write-SubsectionTitle "NIC" # -------------------------------------------------------------------- # Get NICs which are no longer attached to any VM. $LostNICs = Get-AzureRmNetworkInterface | Where { $_.VirtualMachine -eq $null } Remove-Resource -Name "network interface" -Plural "s" ` -Resource $LostNICs ` -RemoveFunc Remove-AzureRmNetworkInterface # -------------------------------------------------------------------- Write-SubsectionTitle "Public IP" # -------------------------------------------------------------------- # Get public IP addresses detached from NICs. $LostIPs = Get-AzureRmPublicIpAddress | Where { $_.IpConfiguration -eq $null -and $_.DnsSettings -eq $null } Remove-Resource -Name "public IP address" -Plural "es" ` -Resource $LostIPs ` -RemoveFunc Remove-AzureRmPublicIpAddress # -------------------------------------------------------------------- Write-SubsectionTitle "NSG" # -------------------------------------------------------------------- # Get network security groups (NSGs) detached from both NICs and subnets. $LostNSGs = Get-AzureRmNetworkSecurityGroup | Where {$_.NetworkInterfaces.Count -eq 0 -and $_.Subnets.Count -eq 0} Remove-Resource -Name "network security group" -Plural "s" ` -Resource $LostNSGs ` -RemoveFunc Remove-AzureRmNetworkSecurityGroup # -------------------------------------------------------------------- Write-SubsectionTitle "VNet" # -------------------------------------------------------------------- # Get virtual networks which have no public IP address. $LostVNets = Get-AzureRMVirtualNetwork | Where { $_.Subnets.IpConfigurations.Count -eq 0 } Remove-Resource -Name "virtual network" -Plural "s" ` -Resource $LostVNets ` -RemoveFunc Remove-AzureRMVirtualNetwork } if ($RemoveResourceGroup) { Write-SectionTitle "Remove Unused ResourceGroup" $UsedRGNames = Get-AzureRmResource | Select -ExpandProperty ResourceGroupname | Get-Unique $LostRGs = Get-AzureRmResourceGroup | Where { $UsedRGNames -notcontains $_.ResourceGroupname } Remove-Resource -Name "resource group" -Plural "s" ` -Resource $LostRGs ` -RemoveFunc Remove-AzureRmResourceGroup } } # end of foreach subscription