Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch file",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "main.go",
"args": ["--ttl", "4h", "--az-cli", "--dry-run",],
"env": {
"SUBSCRIPTION_ID": "xxxxx-xxxx-xxxx-xxxx-xxxxx",
},
}

]
}
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
FROM alpine:3.18
COPY bin/rg-cleanup /usr/local/bin
ENTRYPOINT [ "rg-cleanup" ]
FROM mcr.microsoft.com/cbl-mariner/base/core:2.0
RUN tdnf install -y azure-cli jq && tdnf clean all
COPY rg-cleanup.sh /usr/local/bin
COPY bin/rg-cleanup ./bin
ENTRYPOINT [ "rg-cleanup.sh" ]
Comment thread
jsturtevant marked this conversation as resolved.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
IMAGE_REGISTRY ?= k8sprow.azurecr.io
IMAGE_REGISTRY ?= k8sprowcomm.azurecr.io
IMAGE_NAME := rg-cleanup
IMAGE_VERSION ?= v0.2.0
IMAGE_VERSION ?= v0.4.6

.PHONY: all
all: build
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,30 @@ az deployment group create -g "<rg-name>" -f ./templates/rg-cleaner-logic-app-ua
regex="<regex expression patter>" # Optional
# Other optional parameters are available, please refer to the deployment bicep file.
```

### Orphaned Role Assignment cleanup

The tool also provides a way to clean up orphaned role assignments. This is useful when you have role assignments that are no longer associated with any resource groups. To enable this feature, you can use the `--role-assignment` flag when running the tool.

This relies on a correlated query with the Microsoft Graph API, the permissions for which can be granted with a script like this:

```powershell
Connect-AzureAD

$GraphAppId = "00000003-0000-0000-c000-000000000000" # Don't change this value
$NameOfMSI = "rg-cleanup-og"
$Permissions = @(
"Application.Read.All",
"Directory.Read.All",
"User.Read.All",
)

$MSI = (Get-AzureADServicePrincipal -Filter "displayName eq '$NameOfMSI'")
Start-Sleep -Seconds 10
$GraphServicePrincipal = Get-AzureADServicePrincipal -Filter "appId eq '$GraphAppId'"

foreach ($PermissionName in $Permissions) {
$AppRole = $GraphServicePrincipal.AppRoles | Where-Object { $_.Value -eq $PermissionName -and $_.AllowedMemberTypes -contains "Application" }
New-AzureAdServiceAppRoleAssignment -ObjectId $MSI.ObjectId -PrincipalId $MSI.ObjectId -ResourceId $GraphServicePrincipal.ObjectId -Id $AppRole.Id
}
```
34 changes: 24 additions & 10 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,19 @@ type options struct {
ttl time.Duration
identity bool
regex string
cli bool
}

func (o *options) validate() error {
if o.clientID == "" {
return fmt.Errorf("$%s is empty", aadClientIDEnvVar)
}
if o.subscriptionID == "" {
return fmt.Errorf("$%s is empty", subscriptionIDEnvVar)
}
if o.cli {
return nil
}
if o.clientID == "" {
return fmt.Errorf("$%s is empty", aadClientIDEnvVar)
}
if o.identity {
return nil
}
Expand All @@ -77,6 +81,7 @@ func defineOptions() *options {
o.subscriptionID = os.Getenv(subscriptionIDEnvVar)
flag.BoolVar(&o.dryRun, "dry-run", false, "Set to true if we should run the cleanup tool without deleting the resource groups.")
flag.BoolVar(&o.identity, "identity", false, "Set to true if we should user-assigned identity for AUTH")
flag.BoolVar(&o.cli, "az-cli", false, "Set to true if we should use az cli for AUTH")
flag.DurationVar(&o.ttl, "ttl", defaultTTL, "The duration we allow resource groups to live before we consider them to be stale.")
flag.StringVar(&o.regex, "regex", defaultRegex, "Only delete resource groups matching regex")
flag.Parse()
Expand All @@ -85,6 +90,7 @@ func defineOptions() *options {

func main() {
log.Println("Initializing rg-cleanup")
log.Printf("args: %v\n", os.Args)

o := defineOptions()
if err := o.validate(); err != nil {
Expand All @@ -96,7 +102,7 @@ func main() {
log.Println("Dry-run enabled - printing logs but not actually deleting resource groups")
}

r, err := getResourceGroupClient(o.clientID, o.clientSecret, o.tenantID, o.subscriptionID, o.identity)
r, err := getResourceGroupClient(*o)
if err != nil {
log.Printf("Error when obtaining resource group client: %v", err)
panic(err)
Expand Down Expand Up @@ -193,34 +199,42 @@ func regexMatchesResourceGroupName(regex string, rgName string) (bool, error) {
return false, nil
}

func getResourceGroupClient(clientID, clientSecret, tenantID, subscriptionID string, identity bool) (*armresources.ResourceGroupsClient, error) {
func getResourceGroupClient(o options) (*armresources.ResourceGroupsClient, error) {
options := arm.ClientOptions{
ClientOptions: azcore.ClientOptions{
Cloud: cloud.AzurePublic,
},
}
possibleTokens := []azcore.TokenCredential{}
if identity {
if o.identity {
micOptions := azidentity.ManagedIdentityCredentialOptions{
ID: azidentity.ClientID(clientID),
ID: azidentity.ClientID(o.clientID),
}
miCred, err := azidentity.NewManagedIdentityCredential(&micOptions)
if err != nil {
return nil, err
}
possibleTokens = append(possibleTokens, miCred)
} else {
spCred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, nil)
} else if o.clientSecret != "" {
spCred, err := azidentity.NewClientSecretCredential(o.tenantID, o.clientID, o.clientSecret, nil)
if err != nil {
return nil, err
}
possibleTokens = append(possibleTokens, spCred)
} else if o.cli {
cliCred, err := azidentity.NewAzureCLICredential(nil)
if err != nil {
return nil, err
}
possibleTokens = append(possibleTokens, cliCred)
} else {
log.Println("unknown login option. login may not succeed")
}
chain, err := azidentity.NewChainedTokenCredential(possibleTokens, nil)
if err != nil {
return nil, err
}
resourceGroupClient, err := armresources.NewResourceGroupsClient(subscriptionID, chain, &options)
resourceGroupClient, err := armresources.NewResourceGroupsClient(o.subscriptionID, chain, &options)
if err != nil {
return nil, err
}
Expand Down
35 changes: 35 additions & 0 deletions rg-cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/bash

set -euo pipefail

# Parse out --role-assignments flag
role_assignments_flag=false
while [[ "$#" -gt 0 ]]; do
case $1 in
--role-assignments) role_assignments_flag=true ;;
*) args+=("$1") ;; # Store other arguments in an array
esac
shift
done

# Call resource group cleanup binary with its command-line arguments
./bin/rg-cleanup "${args[@]}"

# Exit if not cleaning up unattached role assignments
if [ "$role_assignments_flag" != true ]; then
echo "Skipping unattached role assignment cleanup..."
exit 0
fi

# Clean up unattached role assignments
az login --identity
az account set --subscription "${SUBSCRIPTION_ID}"

assignments=$(az role assignment list --scope "/subscriptions/$SUBSCRIPTION_ID" -o tsv --query "[?principalName==''].id")
if [ -z "$assignments" ]; then
echo "No unattached role assignments found."
exit 0
fi
echo "Deleting unattached role assignments:"
echo "$assignments"
xargs az role assignment delete --ids <<< "$assignments"
126 changes: 108 additions & 18 deletions templates/rg-cleaner-logic-app-uami.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ param aci string = 'aci'
param dryrun bool = true
param ttl string = ''
param regex string = ''
param role_assignments bool = true
param csubscription string = subscription().subscriptionId
param location string = resourceGroup().location
param rg_name string = resourceGroup().name

var default_container_cmd = [
'rg-cleanup'
'rg-cleanup.sh'
'--identity'
]
var dryrun_cmd = [
Expand All @@ -27,9 +28,12 @@ var regex_cmd = [
'--regex'
regex
]
var role_assignments_cmd = [
'--role-assignments'
]
var add_regex_cmd = concat(default_container_cmd, empty(regex) ? [] : regex_cmd)
var add_ttl_cmd = concat(add_regex_cmd, empty(ttl) ? [] : ttl_cmd)
var container_command = concat(add_ttl_cmd, dryrun ? dryrun_cmd : [])
var container_command = concat(add_ttl_cmd, dryrun ? dryrun_cmd : [], role_assignments ? role_assignments_cmd : [])

var encoded_sub = uriComponent(csubscription)
var encoded_rg = uriComponent(rg_name)
Expand All @@ -43,7 +47,7 @@ var cg_id = '${rg_id}/providers/Microsoft.ContainerInstance/containerGroups/${en
var cn_id = '${cg_id}/containers/${encoded_cn}'
var aci_api_id = '/subscriptions/${csubscription}/providers/Microsoft.Web/locations/${location}/managedApis/aci'

resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = {
resource logic_app 'Microsoft.Logic/workflows@2019-05-01' = {
name: logic_app_name
location: location
identity: {
Expand Down Expand Up @@ -136,20 +140,6 @@ resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = {
}
}
}
Delay: {
runAfter: {
Create_or_update_a_container_group: [
'Succeeded'
]
}
type: 'Wait'
inputs: {
interval: {
count: 1
unit: 'Minute'
}
}
}
Delete_a_container_group: {
runAfter: {
Get_logs_from_a_container_instance: [
Expand All @@ -175,7 +165,7 @@ resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = {
}
Get_logs_from_a_container_instance: {
runAfter: {
Delay: [
Until: [
'Succeeded'
]
}
Expand All @@ -193,6 +183,106 @@ resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = {
}
}
}
Get_properties_of_a_container_group: {
runAfter: {
Create_or_update_a_container_group: [
'Succeeded'
]
}
type: 'ApiConnection'
inputs: {
host: {
connection: {
name: '@parameters(\'$connections\')[\'aci\'][\'connectionId\']'
}
}
method: 'get'
path: cg_id
queries: {
'x-ms-api-version': '2019-12-01'
}
}
}
Initialize_variable: {
runAfter: {
Get_properties_of_a_container_group: [
'Succeeded'
]
}
type: 'InitializeVariable'
inputs: {
variables: [
{
name: 'complete'
type: 'string'
value: '@body(\'Get_properties_of_a_container_group\')?[\'properties\']?[\'instanceView\']?[\'state\']'
}
]
}
}
Until: {
actions: {
Get_properties_of_a_container_group_loop: {
type: 'ApiConnection'
inputs: {
host: {
connection: {
name: '@parameters(\'$connections\')[\'aci\'][\'connectionId\']'
}
}
method: 'get'
path: cg_id
queries: {
'x-ms-api-version': '2019-12-01'
}
}
}
// this works because there is only one container in the group, otherwise it might overwrite the variable
For_each: {
foreach: '@body(\'Get_properties_of_a_container_group_loop\')[\'properties\'][\'containers\']'
actions: {
Set_variable: {
type: 'SetVariable'
inputs: {
name: 'complete'
value: '@items(\'For_each\')?[\'properties\']?[\'instanceView\']?[\'currentState\']?[\'state\']'
}
}
}
runAfter: {
Get_properties_of_a_container_group_loop: [
'Succeeded'
]
}
type: 'Foreach'
}
Delay: {
runAfter: {
For_each: [
'Succeeded'
]
}
type: 'Wait'
inputs: {
interval: {
count: 1
unit: 'Minute'
}
}
}
}
runAfter: {
Initialize_variable: [
'Succeeded'
]
}
expression: '@equals(variables(\'complete\'),\'Terminated\')'
limit: {
count: 60
timeout: 'PT1H'
}
type: 'Until'
}
}
outputs: {
}
Expand Down
Loading