main.go (214 lines of code) (raw):
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"regexp"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
)
const (
defaultTTL = 3 * 24 * time.Hour
defaultRegex = ""
creationTimestampTag = "creationTimestamp"
doNotDeleteTag = "DO-NOT-DELETE"
aadClientIDEnvVar = "AAD_CLIENT_ID"
aadClientSecretEnvVar = "AAD_CLIENT_SECRET"
tenantIDEnvVar = "TENANT_ID"
subscriptionIDEnvVar = "SUBSCRIPTION_ID"
)
var rfc3339Layouts = []string{
time.RFC3339,
time.RFC3339Nano,
// The following two layouts are also acceptable
// RFC3339 layouts. See:
// https://github.com/golang/go/issues/20555#issuecomment-440348440
"2006-01-02T15:04:05+0000",
"2006-01-02T15:04:05-0000",
"2006-01-02T15:04:05-00:00",
"2006-01-02T15:04:05+00:00",
}
type options struct {
clientID string
clientSecret string
tenantID string
subscriptionID string
dryRun bool
ttl time.Duration
identity bool
regex string
cli bool
}
func (o *options) validate() error {
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
}
if o.clientSecret == "" {
return fmt.Errorf("$%s is empty", aadClientSecretEnvVar)
}
if o.tenantID == "" {
return fmt.Errorf("$%s is empty", tenantIDEnvVar)
}
return nil
}
func defineOptions() *options {
o := options{}
o.clientID = os.Getenv(aadClientIDEnvVar)
o.clientSecret = os.Getenv(aadClientSecretEnvVar)
o.tenantID = os.Getenv(tenantIDEnvVar)
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()
return &o
}
func main() {
log.Println("Initializing rg-cleanup")
log.Printf("args: %v\n", os.Args)
o := defineOptions()
if err := o.validate(); err != nil {
log.Printf("Error when validating options: %v", err)
panic(err)
}
if o.dryRun {
log.Println("Dry-run enabled - printing logs but not actually deleting resource groups")
}
r, err := getResourceGroupClient(*o)
if err != nil {
log.Printf("Error when obtaining resource group client: %v", err)
panic(err)
}
if err := run(context.Background(), r, o.ttl, o.dryRun, o.regex); err != nil {
log.Printf("Error when running rg-cleanup: %v", err)
panic(err)
}
}
func run(ctx context.Context, r *armresources.ResourceGroupsClient, ttl time.Duration, dryRun bool, regex string) error {
log.Println("Scanning for stale resource groups")
pager := r.NewListPager(nil)
for pager.More() {
nextResult, err := pager.NextPage(ctx)
if err != nil {
return fmt.Errorf("error when iterating resource groups: %v", err)
}
for _, rg := range nextResult.Value {
rgName := *rg.Name
if age, ok := shouldDeleteResourceGroup(rg, ttl, regex); ok {
if dryRun {
log.Printf("Dry-run: skip deletion of eligible resource group '%s' (age: %s)", rgName, age)
continue
}
// Start the delete without waiting for it to complete.
log.Printf("Beginning to delete resource group '%s' (age: %s)", rgName, age)
_, err = r.BeginDelete(ctx, rgName, nil)
if err != nil {
log.Printf("Error when deleting %s: %v", rgName, err)
}
}
}
}
return nil
}
func shouldDeleteResourceGroup(rg *armresources.ResourceGroup, ttl time.Duration, regex string) (string, bool) {
if _, ok := rg.Tags[doNotDeleteTag]; ok {
return "", false
}
if regex != "" {
match, err := regexMatchesResourceGroupName(regex, *rg.Name)
if err != nil {
log.Printf("failed to regex Resource Group Name: %s", err)
return "", false
}
if !match {
log.Printf("RG '%s' did not match regex", *rg.Name)
return "", false
}
log.Printf("RG '%s' matched regex '%s'", *rg.Name, regex)
}
creationTimestamp, ok := rg.Tags[creationTimestampTag]
if !ok {
return fmt.Sprintf("probably a long time because it does not have a '%s' tag. Found tags: %v", creationTimestampTag, rg.Tags), true
}
var t time.Time
var err error
for _, layout := range rfc3339Layouts {
t, err = time.Parse(layout, *creationTimestamp)
if err == nil {
break
}
}
if err != nil {
log.Printf("failed to parse timestamp: %s", err)
return "", false
}
return fmt.Sprintf("%d days (%d hours)", int(time.Since(t).Hours()/24), int(time.Since(t).Hours())), time.Since(t) >= ttl
}
func regexMatchesResourceGroupName(regex string, rgName string) (bool, error) {
if regex != "" {
rgx, err := regexp.Compile(regex)
if err != nil {
return false, fmt.Errorf("failed to compile regex: %v", err)
}
match := rgx.FindString(rgName)
if match != rgName {
return false, nil
}
return true, nil
}
return false, nil
}
func getResourceGroupClient(o options) (*armresources.ResourceGroupsClient, error) {
options := arm.ClientOptions{
ClientOptions: azcore.ClientOptions{
Cloud: cloud.AzurePublic,
},
}
possibleTokens := []azcore.TokenCredential{}
if o.identity {
micOptions := azidentity.ManagedIdentityCredentialOptions{
ID: azidentity.ClientID(o.clientID),
}
miCred, err := azidentity.NewManagedIdentityCredential(&micOptions)
if err != nil {
return nil, err
}
possibleTokens = append(possibleTokens, miCred)
} 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(o.subscriptionID, chain, &options)
if err != nil {
return nil, err
}
return resourceGroupClient, nil
}