Skip to content

Commit ab2c931

Browse files
author
Mauritz Uph
committed
add --all-contexts command
1 parent 507fa1d commit ab2c931

File tree

3 files changed

+158
-79
lines changed

3 files changed

+158
-79
lines changed

README.md

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,23 @@
11
# Azure ACR Purge Control 🗑️
22

3-
``acrpurgectl`` is a tool that extends the az-cli acr deletion command.
3+
``acrpurgectl`` is a tool that extends the az acr purge deletion command.
44
It parses all images from the Kubernetes (k8s) contexts that you provide and ensures that no
55
image currently running in your cluster is deleted.
66

7-
## Key Features
8-
- Takes into account the list of Kubernetes contexts during the deletion process, ensuring no running image is deleted.
9-
- Eliminates the need to pay for ACR tasks.
10-
- Offers a familiar workflow inspired by the "terraform plan" and "terraform apply" approach.
11-
- Allows for the use of the "dry-run" command to preview tags before actual deletion, instilling confidence in your actions.
12-
137
## CLI Commands
148

159
Here are the available CLI commands and their descriptions:
1610

17-
| Command | Description |
18-
|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
19-
| `--registry <registry_name>` | Set the name of the Azure Container Registry. |
20-
| `--repository <repository_name>` | Set the name of the repository in your Azure Container Registry. |
21-
| `--subscription <subscription_id>` | Set the ID of the Azure subscription. If not specified, the default one will be used. |
22-
| `--timestamp <cutoff_timestamp>` | Set the cutoff timestamp. All images before this timestamp will be deleted. Default: 01/01/2024. |
23-
| `--delay <delay_in_seconds>` | Set the delay (in seconds) between deletion requests. Default: 1 second. |
24-
| `--contexts <context_list>` | Comma-separated list of Kubernetes contexts. The deletion process will not start if any 'imageToDelete' is running in a cluster from the context list. |
25-
| `--dry-run` | Perform a dry run, printing the tags to be deleted but do not delete them. |
11+
| Flag | Default Value | Description |
12+
|----------------|---------------|--------------------------------------------------------------------------------------------------------|
13+
| `registry` | "" | Name of the Azure Container Registry. |
14+
| `repository` | "" | Name of the repository in your registry. |
15+
| `subscription` | "" | ID of the subscription. If not specified it will use the default one. |
16+
| `contexts` | "" | Comma-separated list of Kubernetes contexts. Deletion process will not start if image is running here. |
17+
| `all-contexts` | false | Deletion process will not start if image is running in a cluster from your kubeconfig contexts. |
18+
| `ago` | "360d" | Time duration in the past. Number followed by a duration type: 's' for seconds, 'm' for minutes, etc. |
19+
| `dry-run` | false | Perform a dry run, print tags to be deleted but do not delete them. |
20+
2621

2722
## Usage with Docker
2823

@@ -43,7 +38,7 @@ az login
4338
Execute Azure ACR Purge with your desired parameters. For example:
4439

4540
```sh
46-
./acrpurgectl --repository test/repo-a --registry testregistry --subscription 1111-2222-3333-4444 --timestamp 01/02/2021
41+
./acrpurgectl --repository test/repo-a --registry testregistry --subscription 1111-2222-3333-4444 --ago 360d
4742
```
4843

4944
If you want to use the `--contexts` option, you need to share your local kubeconfig file with the Docker container, to allow Azure ACR Purge to access your Kubernetes contexts:
@@ -55,7 +50,7 @@ docker run -it --rm -v /path/to/your/.kube/config:/root/.kube/config ghcr.io/h3a
5550
Then execute Azure ACR Purge with the `--contexts` parameter:
5651

5752
```sh
58-
./acrpurgectl --repository test/repo-a --registry testregistry --subscription 1111-2222-3333-4444 --timestamp 01/02/2021 --contexts context1,context2
53+
./acrpurgectl --repository test/repo-a --registry testregistry --subscription 1111-2222-3333-4444 --ago 360d --contexts context1,context2
5954
```
6055

6156
This will initiate the process to delete old images from the specified repository in your Azure Container Registry based on the provided parameters.

cmd/main.go

Lines changed: 145 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
package main
22

33
import (
4+
"bufio"
45
"encoding/json"
56
"flag"
67
"fmt"
78
"log"
89
"os/exec"
10+
"strconv"
911
"strings"
1012
"time"
11-
12-
"github.com/araddon/dateparse"
1313
)
1414

1515
type ImageMetadata struct {
@@ -32,25 +32,103 @@ type ImageMetadata struct {
3232

3333
const Layout = "2006-01-02T15:04:05"
3434

35-
func isImageRunningInCluster(clusterImages []string, imageToDelete ImageMetadata, repository string) (string, error) {
36-
for _, clusterImage := range clusterImages {
37-
for _, tag := range imageToDelete.Tags {
38-
if strings.Contains(clusterImage, fmt.Sprintf("%s:%s", repository, tag)) {
39-
return clusterImage, fmt.Errorf("image is running in your provided k8s clusters")
35+
func isImageRunningInCluster(contexts map[string][]string, imageToDelete ImageMetadata, repository string, registry string) error {
36+
for context, images := range contexts {
37+
for _, image := range images {
38+
for _, tag := range imageToDelete.Tags {
39+
if image == fmt.Sprintf("%s.azurecr.io/%s:%s", registry, repository, tag) {
40+
return fmt.Errorf("image with tag %s is running in the k8s context %s", tag, context)
41+
}
4042
}
4143
}
4244
}
4345

44-
return "", nil
46+
return nil
47+
}
48+
49+
func parseKubectlContexts(k8sImages *[]string) error {
50+
output, err := exec.Command(
51+
"bash",
52+
"-c",
53+
"kubectl config get-contexts -o name",
54+
).Output()
55+
56+
if err != nil {
57+
return err
58+
}
59+
60+
for _, context := range strings.Split(string(output), "\n") {
61+
if len(context) <= 1 {
62+
continue
63+
}
64+
65+
*k8sImages = append(*k8sImages, context)
66+
}
67+
68+
return nil
69+
}
70+
71+
func watchCmd(cmd string) error {
72+
azPurge := exec.Command("bash", "-c", fmt.Sprintf("%s", cmd))
73+
74+
stdout, err := azPurge.StdoutPipe()
75+
if err != nil {
76+
return err
77+
}
78+
79+
err = azPurge.Start()
80+
if err != nil {
81+
return err
82+
}
83+
84+
scanner := bufio.NewScanner(stdout)
85+
go func() {
86+
for scanner.Scan() {
87+
log.Println(scanner.Text())
88+
}
89+
if err := scanner.Err(); err != nil {
90+
log.Fatal(err)
91+
}
92+
}()
93+
94+
err = azPurge.Wait()
95+
if err != nil {
96+
return err
97+
}
98+
99+
return nil
100+
}
101+
102+
func parseAgo(ago *string) (time.Time, error) {
103+
durationStr := (*ago)[:len(*ago)-1]
104+
durationType := string((*ago)[len(*ago)-1])
105+
106+
durationInt, err := strconv.Atoi(durationStr)
107+
if err != nil {
108+
return time.Now(), fmt.Errorf("invalid duration number")
109+
}
110+
111+
switch strings.ToLower(durationType) {
112+
case "s":
113+
return time.Now().Add(time.Duration(-durationInt) * time.Second), nil
114+
case "m":
115+
return time.Now().Add(time.Duration(-durationInt) * time.Minute), nil
116+
case "h":
117+
return time.Now().Add(time.Duration(-durationInt) * time.Hour), nil
118+
case "d":
119+
return time.Now().AddDate(0, 0, -durationInt), nil
120+
default:
121+
return time.Now(), fmt.Errorf("invalid duration type. Please use 's' for seconds, 'm' for minutes, 'h' for hours, or 'd' for days")
122+
}
45123
}
46124

47125
func main() {
48126
registryName := flag.String("registry", "", "Name of the Azure Container Registry")
49127
repositoryName := flag.String("repository", "", "Name of the repository in your registry")
50128
subscriptionId := flag.String("subscription", "", "ID of the subscription. If not specified it will use the default one")
51129
contexts := flag.String("contexts", "", "Comma-separated list of Kubernetes contexts. The deletion process will not start if any 'imageToDelete' is running in a cluster from the context list")
52-
deletionCutoffTimestamp := flag.String("timestamp", "01/01/2024", "All Images before the timestamp will get deleted")
53-
delay := flag.Float64("delay", 1, "Delay between deletion requests")
130+
allContexts := flag.Bool("all-contexts", false, "The deletion process will not start if any 'imageToDelete' is running in a cluster from your kubeconfig contexts")
131+
ago := flag.String("ago", "360d", "Time duration in the past. Expects a number followed by a duration type: 's' for seconds, 'm' for minutes, 'h' for hours, 'd' for days.")
54132
dryRunMode := flag.Bool("dry-run", false, "Perform a dry run, print tags to be deleted but do not delete them")
55133
flag.Parse()
56134

@@ -59,47 +137,73 @@ func main() {
59137
return
60138
}
61139

140+
if *ago == "" {
141+
log.Println("You must provide a duration ago")
142+
return
143+
}
144+
62145
if *subscriptionId != "" {
63146
_, err := exec.Command("bash", "-c", fmt.Sprintf("az account set --subscription %s", *subscriptionId)).Output()
64147
if err != nil {
65148
log.Println("Failed to set az subscription: ", err)
149+
log.Println("Are you logged in? (az login)")
66150
return
67151
}
68152
}
69153

70-
var k8sImages []string
71-
if len(*contexts) >= 0 {
154+
providedContexts := make([]string, 0)
155+
if len(*contexts) > 0 {
72156
for _, context := range strings.Split(*contexts, ",") {
157+
if len(context) <= 1 {
158+
continue
159+
}
160+
161+
providedContexts = append(providedContexts, strings.TrimSpace(context))
162+
}
163+
}
164+
165+
if len(providedContexts) == 0 && *allContexts {
166+
err := parseKubectlContexts(&providedContexts)
167+
if err != nil {
168+
log.Fatalf("Error parsing all context from your kube config: %s", err)
169+
}
170+
}
171+
172+
k8sImages := map[string][]string{}
173+
if len(providedContexts) > 0 {
174+
log.Printf("Parsing images from the following contexts: %s\n", strings.Join(providedContexts, ","))
175+
176+
for _, context := range providedContexts {
73177
output, err := exec.Command(
74178
"bash",
75179
"-c",
76180
fmt.Sprintf("kubectl get pods --context %s --all-namespaces -o jsonpath=\"{.items[*].spec.containers[*].image}\"", context),
77181
).Output()
78182
if err != nil {
79-
log.Println("Failed to set az subscription: ", err)
80-
return
183+
log.Printf("Failed to get images from context: %s with error %s \n", context, err)
184+
continue
81185
}
82186

83187
for _, image := range strings.Split(string(output), " ") {
84-
k8sImages = append(k8sImages, image)
188+
k8sImages[context] = append(k8sImages[context], image)
85189
}
86190
}
87191
}
88192

89-
parsedDate, err := dateparse.ParseAny(*deletionCutoffTimestamp)
193+
dateAgo, err := parseAgo(ago)
90194
if err != nil {
91-
log.Println("Unable to parse the provided date: ", err)
195+
log.Println("Unable to parse the provided ago timespan: ", err)
92196
return
93197
}
94198

95199
listManifestsCmd := fmt.Sprintf(
96200
"az acr manifest list-metadata --name %s --registry %s --orderby time_asc --query \"[?lastUpdateTime < '%s']\"",
97-
*repositoryName, *registryName, parsedDate.Format(Layout),
201+
*repositoryName, *registryName, dateAgo.Format(Layout),
98202
)
99-
100203
manifestInformation, err := exec.Command("bash", "-c", listManifestsCmd).Output()
101204
if err != nil {
102-
log.Println("Failed to retrieve manifest information: ", err)
205+
log.Printf("Failed to retrieve manifest information from repository %s. Error: %s \n", *repositoryName, err)
206+
return
103207
}
104208

105209
var imageMetadataList []ImageMetadata
@@ -110,44 +214,42 @@ func main() {
110214
}
111215

112216
if len(imageMetadataList) == 0 {
113-
log.Printf("No Docker Images found which succeed the deletionCutoffTimestamp %s\n", parsedDate)
217+
log.Printf("No Docker Images found which succeed the date %s\n", dateAgo)
114218
return
115219
}
116220

117-
var imagesToDelete []ImageMetadata
221+
const bytesToGB = 1024 * 1024 * 1024
222+
totals := map[string]int{
223+
"images": 0,
224+
"bytes": 0,
225+
}
118226
for _, metadata := range imageMetadataList {
119227
if len(metadata.Tags) == 0 {
120228
continue
121229
}
122230

123231
if len(k8sImages) != 0 {
124-
image, err := isImageRunningInCluster(k8sImages, metadata, *repositoryName)
232+
err = isImageRunningInCluster(k8sImages, metadata, *repositoryName, *registryName)
125233
if err != nil {
126-
log.Fatalf("Error: The Image %s is running in one of your cluster. Please reconsider your deletion timestamp. \n", image)
234+
log.Fatalf("Error: %s", err)
235+
return
127236
}
128237
}
129238

130-
if *dryRunMode {
131-
log.Printf("[DRY-RUN] Docker Image %s with tags %s would get deleted. Created Time: %s \n", *repositoryName, strings.Join(metadata.Tags, ","), metadata.CreatedTime)
132-
continue
133-
}
134-
135-
if len(metadata.Digest) > 0 {
136-
imagesToDelete = append(imagesToDelete, metadata)
137-
}
239+
log.Printf("[DRY-RUN] Docker Image %s with tags %s would get deleted. Created Time: %s \n", *repositoryName, strings.Join(metadata.Tags, ","), metadata.CreatedTime)
240+
totals["images"]++
241+
totals["bytes"] += metadata.ImageSize
138242
}
139243

140-
if len(imagesToDelete) == 0 {
141-
return
142-
}
244+
log.Printf("Found %d docker images with approximately %.2f GB worth of data to delete.", totals["images"], float64(totals["bytes"])/float64(bytesToGB))
143245

144-
amountImages := 0
145-
for _, imageToDelete := range imagesToDelete {
146-
log.Printf("Docker Image %s with tags %s will get deleted. Created Time: %s \n", *repositoryName, strings.Join(imageToDelete.Tags, ","), imageToDelete.CreatedTime)
147-
amountImages++
246+
azPurgeCmd := fmt.Sprintf("az acr run --cmd=\"acr purge --filter '%s:.*' --ago=%s --untagged \" --registry %s /dev/null", *repositoryName, *ago, *registryName)
247+
if *dryRunMode {
248+
azPurgeCmd = fmt.Sprintf("az acr run --cmd=\"acr purge --filter '%s:.*' --ago=%s --untagged --dry-run\" --registry %s /dev/null", *repositoryName, *ago, *registryName)
148249
}
149250

150-
log.Printf("%d Images will get deleted. Do you want to perfom the deletion? Please answer with yes\n", amountImages)
251+
log.Printf("Generated az purge cmd: %s", azPurgeCmd)
252+
log.Printf("Do you want to perfom the deletion? Please answer with yes")
151253

152254
var response string
153255
_, err = fmt.Scanln(&response)
@@ -161,24 +263,8 @@ func main() {
161263
return
162264
}
163265

164-
log.Printf("Starting deletion process with a delay of %f s \n", *delay)
165-
for _, imageToDelete := range imagesToDelete {
166-
if len(imageToDelete.Digest) == 0 {
167-
log.Printf("Skipping image with tags: %s since it has not digest \n", strings.Join(imageToDelete.Tags, ","))
168-
}
169-
170-
deleteManifest := fmt.Sprintf(
171-
"az acr repository delete --name %s --image %s@%s --yes",
172-
*registryName, *repositoryName, imageToDelete.Digest,
173-
)
174-
_, err := exec.Command("bash", "-c", deleteManifest).Output()
175-
if err != nil {
176-
log.Printf("Error fulfilling deletion command: %s\n", err)
177-
}
178-
179-
log.Printf("Deleted image %s with tags: %s \n", *repositoryName, strings.Join(imageToDelete.Tags, ","))
180-
time.Sleep(time.Second * time.Duration(*delay))
266+
err = watchCmd(azPurgeCmd)
267+
if err != nil {
268+
log.Fatalf("Error fulfilling az purge command: %s", err)
181269
}
182-
183-
log.Printf("Done. Goodbye!")
184270
}

go.mod

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
module azure-registry-purge
22

33
go 1.20
4-
5-
require github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de

0 commit comments

Comments
 (0)