Skip to content

Commit 507fa1d

Browse files
author
Mauritz Uph
committed
Initial commit
0 parents  commit 507fa1d

File tree

8 files changed

+378
-0
lines changed

8 files changed

+378
-0
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Create and publish a Docker image
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
env:
8+
REGISTRY: ghcr.io
9+
IMAGE_NAME: ${{ github.repository }}
10+
11+
jobs:
12+
build-and-push-image:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
packages: write
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v3
20+
- name: Log in to the Container registry
21+
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
22+
with:
23+
registry: ${{ env.REGISTRY }}
24+
username: ${{ github.actor }}
25+
password: ${{ secrets.GITHUB_TOKEN }}
26+
- name: Extract metadata (tags, labels) for Docker
27+
id: meta
28+
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
29+
with:
30+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
31+
- name: Build and push Docker image
32+
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
33+
with:
34+
context: .
35+
push: true
36+
tags: ${{ steps.meta.outputs.tags }}
37+
labels: ${{ steps.meta.outputs.labels }}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# .github/workflows/release.yaml
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
permissions:
8+
contents: write
9+
packages: write
10+
11+
jobs:
12+
releases-matrix:
13+
name: Release Go Binary
14+
runs-on: ubuntu-latest
15+
strategy:
16+
matrix:
17+
# build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64
18+
goos: [linux, darwin]
19+
goarch: ["386", amd64, arm64]
20+
exclude:
21+
- goarch: "386"
22+
goos: darwin
23+
- goarch: arm64
24+
goos: windows
25+
steps:
26+
- uses: actions/checkout@v3
27+
- uses: wangyoucao577/go-release-action@v1
28+
with:
29+
pre_command: cp cmd/main.go .
30+
github_token: ${{ secrets.GITHUB_TOKEN }}
31+
goos: ${{ matrix.goos }}
32+
goarch: ${{ matrix.goarch }}

.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.idea
2+
3+
# If you prefer the allow list template instead of the deny list, see community template:
4+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
5+
#
6+
# Binaries for programs and plugins
7+
*.exe
8+
*.exe~
9+
*.dll
10+
*.so
11+
*.dylib
12+
13+
# Test binary, built with `go test -c`
14+
*.test
15+
16+
# Output of the go coverage tool, specifically when used with LiteIDE
17+
*.out
18+
19+
# Dependency directories (remove the comment below to include it)
20+
# vendor/
21+
22+
# Go workspace file
23+
go.work

Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM bitnami/kubectl:1.20.9 as kubectl
2+
FROM golang:1.20-alpine as builder
3+
WORKDIR /app
4+
5+
COPY go.sum go.mod ./
6+
COPY cmd/main.go main.go
7+
RUN go mod download
8+
RUN go build -o acrpurgectl main.go
9+
10+
FROM alpine:latest
11+
12+
# Azure-Cli dependencies
13+
RUN apk update
14+
RUN apk add bash py3-pip gcc musl-dev python3-dev libffi-dev openssl-dev cargo make
15+
RUN pip install --upgrade pip
16+
RUN pip install azure-cli
17+
COPY --from=kubectl /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/
18+
19+
WORKDIR /root/
20+
COPY --from=builder /app/acrpurgectl /root

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Azure ACR Purge Control 🗑️
2+
3+
``acrpurgectl`` is a tool that extends the az-cli acr deletion command.
4+
It parses all images from the Kubernetes (k8s) contexts that you provide and ensures that no
5+
image currently running in your cluster is deleted.
6+
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+
13+
## CLI Commands
14+
15+
Here are the available CLI commands and their descriptions:
16+
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. |
26+
27+
## Usage with Docker
28+
29+
You can use Azure ACR Purge with Docker by running the Docker image and logging into your Azure account. Here's how you can do it:
30+
31+
Run the Docker image and access the interactive shell:
32+
33+
```sh
34+
docker run -it --rm ghcr.io/h3adex/acrpurgectl:latest
35+
```
36+
37+
Inside the container's interactive shell, log in to your Azure account using the Azure CLI:
38+
39+
```sh
40+
az login
41+
```
42+
43+
Execute Azure ACR Purge with your desired parameters. For example:
44+
45+
```sh
46+
./acrpurgectl --repository test/repo-a --registry testregistry --subscription 1111-2222-3333-4444 --timestamp 01/02/2021
47+
```
48+
49+
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:
50+
51+
```sh
52+
docker run -it --rm -v /path/to/your/.kube/config:/root/.kube/config ghcr.io/h3adex/acrpurgectl:latest
53+
```
54+
55+
Then execute Azure ACR Purge with the `--contexts` parameter:
56+
57+
```sh
58+
./acrpurgectl --repository test/repo-a --registry testregistry --subscription 1111-2222-3333-4444 --timestamp 01/02/2021 --contexts context1,context2
59+
```
60+
61+
This will initiate the process to delete old images from the specified repository in your Azure Container Registry based on the provided parameters.
62+
Make sure to tailor the commands to your specific needs and repositories. Happy cleaning! 🧹🐳

cmd/main.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"os/exec"
9+
"strings"
10+
"time"
11+
12+
"github.com/araddon/dateparse"
13+
)
14+
15+
type ImageMetadata struct {
16+
Architecture string `json:"architecture"`
17+
ChangeableAttributes struct {
18+
DeleteEnabled bool `json:"deleteEnabled"`
19+
ListEnabled bool `json:"listEnabled"`
20+
ReadEnabled bool `json:"readEnabled"`
21+
WriteEnabled bool `json:"writeEnabled"`
22+
} `json:"changeableAttributes"`
23+
ConfigMediaType string `json:"configMediaType"`
24+
CreatedTime time.Time `json:"createdTime"`
25+
Digest string `json:"digest"`
26+
ImageSize int `json:"imageSize"`
27+
LastUpdateTime time.Time `json:"lastUpdateTime"`
28+
MediaType string `json:"mediaType"`
29+
Os string `json:"os"`
30+
Tags []string `json:"tags"`
31+
}
32+
33+
const Layout = "2006-01-02T15:04:05"
34+
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")
40+
}
41+
}
42+
}
43+
44+
return "", nil
45+
}
46+
47+
func main() {
48+
registryName := flag.String("registry", "", "Name of the Azure Container Registry")
49+
repositoryName := flag.String("repository", "", "Name of the repository in your registry")
50+
subscriptionId := flag.String("subscription", "", "ID of the subscription. If not specified it will use the default one")
51+
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")
54+
dryRunMode := flag.Bool("dry-run", false, "Perform a dry run, print tags to be deleted but do not delete them")
55+
flag.Parse()
56+
57+
if *repositoryName == "" || *registryName == "" {
58+
log.Println("You must provide the registry and repository")
59+
return
60+
}
61+
62+
if *subscriptionId != "" {
63+
_, err := exec.Command("bash", "-c", fmt.Sprintf("az account set --subscription %s", *subscriptionId)).Output()
64+
if err != nil {
65+
log.Println("Failed to set az subscription: ", err)
66+
return
67+
}
68+
}
69+
70+
var k8sImages []string
71+
if len(*contexts) >= 0 {
72+
for _, context := range strings.Split(*contexts, ",") {
73+
output, err := exec.Command(
74+
"bash",
75+
"-c",
76+
fmt.Sprintf("kubectl get pods --context %s --all-namespaces -o jsonpath=\"{.items[*].spec.containers[*].image}\"", context),
77+
).Output()
78+
if err != nil {
79+
log.Println("Failed to set az subscription: ", err)
80+
return
81+
}
82+
83+
for _, image := range strings.Split(string(output), " ") {
84+
k8sImages = append(k8sImages, image)
85+
}
86+
}
87+
}
88+
89+
parsedDate, err := dateparse.ParseAny(*deletionCutoffTimestamp)
90+
if err != nil {
91+
log.Println("Unable to parse the provided date: ", err)
92+
return
93+
}
94+
95+
listManifestsCmd := fmt.Sprintf(
96+
"az acr manifest list-metadata --name %s --registry %s --orderby time_asc --query \"[?lastUpdateTime < '%s']\"",
97+
*repositoryName, *registryName, parsedDate.Format(Layout),
98+
)
99+
100+
manifestInformation, err := exec.Command("bash", "-c", listManifestsCmd).Output()
101+
if err != nil {
102+
log.Println("Failed to retrieve manifest information: ", err)
103+
}
104+
105+
var imageMetadataList []ImageMetadata
106+
err = json.Unmarshal(manifestInformation, &imageMetadataList)
107+
if err != nil {
108+
log.Println("Error reading metadata: ", err)
109+
return
110+
}
111+
112+
if len(imageMetadataList) == 0 {
113+
log.Printf("No Docker Images found which succeed the deletionCutoffTimestamp %s\n", parsedDate)
114+
return
115+
}
116+
117+
var imagesToDelete []ImageMetadata
118+
for _, metadata := range imageMetadataList {
119+
if len(metadata.Tags) == 0 {
120+
continue
121+
}
122+
123+
if len(k8sImages) != 0 {
124+
image, err := isImageRunningInCluster(k8sImages, metadata, *repositoryName)
125+
if err != nil {
126+
log.Fatalf("Error: The Image %s is running in one of your cluster. Please reconsider your deletion timestamp. \n", image)
127+
}
128+
}
129+
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+
}
138+
}
139+
140+
if len(imagesToDelete) == 0 {
141+
return
142+
}
143+
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++
148+
}
149+
150+
log.Printf("%d Images will get deleted. Do you want to perfom the deletion? Please answer with yes\n", amountImages)
151+
152+
var response string
153+
_, err = fmt.Scanln(&response)
154+
if err != nil {
155+
log.Println("Unable to read user input")
156+
return
157+
}
158+
159+
if response != "yes" {
160+
log.Println("Goodbye!")
161+
return
162+
}
163+
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))
181+
}
182+
183+
log.Printf("Done. Goodbye!")
184+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module azure-registry-purge
2+
3+
go 1.20
4+
5+
require github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de

go.sum

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
2+
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
3+
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
4+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5+
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
6+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
7+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
8+
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
9+
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
10+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
11+
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
12+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
13+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
14+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
15+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)