Skip to content

Commit d5294a5

Browse files
authored
Merge pull request #163 from robnester-rh/EC-977
ADD script to prune tags from quay.io
2 parents ae77186 + ed22c6d commit d5294a5

File tree

2 files changed

+365
-0
lines changed

2 files changed

+365
-0
lines changed

prune_quay_tags/README.md

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Quay Tag Pruner
2+
3+
A simple Bash script to list and delete [Quay.io](https://quay.io) repository image tags matching a given pattern that are older than a specified cutoff date. Supports dry-run mode to preview which tags *would* be deleted without actually removing them.
4+
5+
---
6+
7+
## Table of Contents
8+
9+
- [Features](#features)
10+
- [Prerequisites](#prerequisites)
11+
- [Installation](#installation)
12+
- [Usage](#usage)
13+
- [Required Arguments](#required-arguments)
14+
- [Date Filters](#date-filters)
15+
- [Token Authorization](#token-authorization)
16+
- [Dry-Run Mode](#dry-run-mode)
17+
- [Examples](#examples)
18+
- [Authentication](#authentication)
19+
- [Script Output](#script-output)
20+
- [Exit Codes](#exit-codes)
21+
- [Deleting Tags](#deleting-tags)
22+
- [License](#license)
23+
24+
---
25+
## Features
26+
27+
- **Filter** tags by name.
28+
- **Filter** tags by age: either “N days old” or “before a specific date”.
29+
- **Dry‑run** mode to preview which tags would be deleted.
30+
- **Automatic** login detection via Docker, Podman, or Skopeo credential files.
31+
- **Private** repositories supported via token authorization to get tags.
32+
- **Pagination** support for repositories with many tags.
33+
34+
---
35+
36+
## Prerequisites
37+
38+
Make sure the following commands are installed and available in your `PATH`:
39+
40+
- [skopeo](https://github.com/containers/skopeo)
41+
- [jq](https://stedolan.github.io/jq/)
42+
- `curl`
43+
- GNU `date` (for `-d` parsing)
44+
45+
---
46+
47+
## Usage
48+
```
49+
./quay-tag-pruner.sh \
50+
--repo <org/repo> \
51+
--filter <string> \
52+
[--days N | --before YYYY-MM-DD] \
53+
[--token <string>] \
54+
[--dry-run]
55+
```
56+
57+
### Required Arguments
58+
59+
* `--repo <org/repo>`
60+
Quay repository path (e.g. myorg/myproject).
61+
62+
* `--filter <string>`
63+
Regular expression to match tag names (e.g. ^on-pr-).
64+
65+
### Date Filters
66+
(one required)
67+
* `--days N`
68+
Delete tags older than N days.
69+
70+
* `--before YYYY-MM-DD`
71+
Delete tags modified before the given date.
72+
73+
### Token Authorization
74+
* `--token <string>`
75+
A token string, used when accessing private repositories.
76+
**Not** required for publicly accessible repositories.
77+
78+
### Dry Run Mode
79+
* `--dry-run`
80+
Only print which tags would be deleted; do not perform any deletions.
81+
82+
## Examples
83+
84+
**Preview** deletion of tags starting with on-pr- older than 30 days:
85+
86+
```
87+
./quay-tag-pruner.sh \
88+
--repo myorg/myrepo \
89+
--filter 'on-pr-' \
90+
--days 30 \
91+
--dry-run
92+
```
93+
94+
**Delete** tags matching release- modified before April 1, 2025:
95+
96+
```
97+
./quay-tag-pruner.sh \
98+
--repo myorg/myrepo \
99+
--filter 'release-' \
100+
--before 2025-04-01
101+
```
102+
103+
**Delete** tags matching release- modified before April 1, 2025, from a private repo
104+
105+
```
106+
./quay-tag-pruner.sh \
107+
--repo myorg/myrepo \
108+
--filter 'release-' \
109+
--before 2025-04-01 \
110+
--token 'THIS0IS1A3FAKE4TOKEN'
111+
```
112+
113+
## Authentication
114+
The script checks for existing Quay.io credentials in:
115+
116+
1. Docker (`~/.docker/config.json`)
117+
2. Podman/containers (`~/.config/containers/auth.json`)
118+
3. Skopeo runtime (`$XDG_RUNTIME_DIR/containers/auth.json`)
119+
120+
If no credentials are found, log in with one of:
121+
122+
**Docker**
123+
```
124+
$ docker login quay.io
125+
```
126+
**Podman**
127+
```
128+
$ podman login quay.io
129+
```
130+
**Skopeo**
131+
```
132+
$ skopeo login quay.io
133+
```
134+
135+
## Script Output
136+
✅ deleted — tag deleted successfully
137+
❌ failed — deletion attempted but failed
138+
💡 would be deleted — in dry‑run mode
139+
🔢 Total matching tags found
140+
✅ Total tags deleted
141+
142+
Each line is formatted as:
143+
```
144+
<tag-name> <YYYY-MM-DD HH:MM:SS TZ> <status>
145+
```
146+
147+
## Exit Codes
148+
* 0 — Successful completion
149+
* 1 — Missing required argument or dependency, or authentication failure
150+
* \>1 — Unexpected error during execution
151+
152+
## Deleting tags
153+
In Quay, when you delete a tag (whether via the API, the UI, or using a tool such as this), you are only removing the reference (the tag) to an image manifest. The underlying manifest and layer blobs remain in the registry until Quay’s garbage‐collection process reclaims them.
154+
155+
> **NOTE**: What follows are few important notes regarding this script and deleting tags.
156+
157+
* **Tag removal is immediate**
158+
Once the DELETE call succeeds, the tag no longer appears in the tag listing and you can’t pull it by name anymore.
159+
160+
* **Manifests live on until unreferenced**
161+
If the deleted tag was the last one pointing at a given manifest, it becomes “orphaned.” Quay does not immediately purge the orphaned data.
162+
163+
* **Reversion window (“time machine”)**
164+
By default Quay holds deleted and expired tags (and their data) for 14 days, during which you can restore them via the UI or API (assuming your administrator hasn’t shortened that window).
165+
166+
* **Asynchronous garbage collection**
167+
Quay runs a background GC job that scans for unreferenced manifests and then physically deletes their blobs from storage.
168+
169+
**So, in practice**:
170+
* DELETE tag → tag disappears immediately.
171+
* Quay GC (within its next cycle) → orphaned manifests and layers get cleaned up.
172+
* Before GC completes → you can still “undelete” or revert a tag if needed.
173+
174+
## License
175+
This project is released under the Apache License, Version 2.0.

prune_quay_tags/prune_quay_tags.sh

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env bash
2+
# Copyright The Conforma Contributors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
# SPDX-License-Identifier: Apache-2.0
17+
18+
set -euo pipefail
19+
# Exit on error, undefined variable, or pipeline failure
20+
21+
# ---------------------------
22+
# Ensure required commands are available
23+
# ---------------------------
24+
for cmd in skopeo jq curl date; do
25+
if ! command -v "$cmd" >/dev/null 2>&1; then
26+
echo "❌ '$cmd' is not installed or not in your PATH. Please install $cmd."
27+
exit 1
28+
fi
29+
done
30+
31+
# ---------------------------
32+
# Defaults for CLI arguments
33+
# ---------------------------
34+
REPO=""
35+
FILTER=""
36+
DAYS=""
37+
BEFORE=""
38+
QUAY_TOKEN=""
39+
DRY_RUN=false
40+
41+
# ---------------------------
42+
# Parse flags
43+
# ---------------------------
44+
while [[ $# -gt 0 ]]; do
45+
case "$1" in
46+
--repo) REPO="$2"; shift 2;;
47+
--filter) FILTER="$2"; shift 2;;
48+
--days) DAYS="$2"; shift 2;;
49+
--before) BEFORE="$2"; shift 2;;
50+
--token) QUAY_TOKEN="$2"; shift 2;;
51+
--dry-run) DRY_RUN=true; shift;;
52+
*) echo "Usage: $0 --repo <repo> --filter <regex> [--days N | --before YYYY-MM-DD] [--token] [--dry-run]"; exit 1;;
53+
esac
54+
done
55+
56+
# ---------------------------
57+
# Validate required arguments
58+
# ---------------------------
59+
if [[ -z "$REPO" || -z "$FILTER" ]]; then
60+
echo "❌ Missing --repo or --filter"
61+
exit 1
62+
fi
63+
if [[ -z "$DAYS" && -z "$BEFORE" ]]; then
64+
echo "❌ You must supply either --days or --before"
65+
exit 1
66+
fi
67+
68+
# ---------------------------
69+
# Compute cutoff epoch time
70+
# ---------------------------
71+
if [[ -n "$DAYS" ]]; then
72+
CUTOFF=$(date -d "-$DAYS days" +%s)
73+
else
74+
CUTOFF=$(date -d "$BEFORE" +%s)
75+
fi
76+
77+
# ---------------------------
78+
# Echo context to user
79+
# ---------------------------
80+
echo "🔍 Repo: $REPO"
81+
echo "🔎 Filter: $FILTER"
82+
echo "📅 Cutoff: Before $(date -d "@$CUTOFF" +'%Y-%m-%d %H:%M:%S %Z')"
83+
if [[ -n "${QUAY_TOKEN:-}" ]]; then
84+
echo "🔑 Token: ${QUAY_TOKEN:0:4}... (truncated)"
85+
fi
86+
$DRY_RUN && echo "🧪 Dry‑run: ON"
87+
88+
# ---------------------------
89+
# Authentication check
90+
# ---------------------------
91+
# Look for quay.io credentials in various files
92+
if jq -e '.auths["quay.io"]' ~/.docker/config.json >/dev/null 2>&1; then
93+
echo "✅ 🐋 Logged in via Docker credentials"
94+
# check to Podman/containers
95+
elif jq -e '."quay.io"' ~/.config/containers/auth.json >/dev/null 2>&1; then
96+
echo "✅ 🦭 Logged in via containers/auth.json"
97+
# check Skopeo
98+
elif jq -e '.auths["quay.io"]' $XDG_RUNTIME_DIR/containers/auth.json >/dev/null 2>&1; then
99+
echo "✅ 📦 Logged in via \$XDG_RUNTIME_DIR/containers/auth.json"
100+
# no creds? Suggest logging in
101+
else
102+
echo "❌ No quay.io entry found in your credential files"
103+
echo "Please log in to quay.io using Podman, Docker, or Skopeo first."
104+
exit 1
105+
fi
106+
107+
echo ""
108+
109+
# ---------------------------
110+
# Pagination & tag fetching
111+
# ---------------------------
112+
PAGE=1
113+
BASE_URL="https://quay.io/api/v1/repository/${REPO}/tag/?limit=100"
114+
DELETED=0
115+
FOUND=0
116+
117+
while :; do
118+
# Set up curl arguments
119+
# -s: silent mode
120+
# -G: use GET method
121+
curl_args=(-s -G)
122+
123+
# If we have a token, add it to the curl request as a header
124+
# -H: add a header
125+
if [[ -n "${QUAY_TOKEN:-}" ]]; then
126+
curl_args+=( -H "Authorization: Bearer ${QUAY_TOKEN}" )
127+
fi
128+
129+
# now append the URL + query args
130+
# --data-urlencode: URL-encode the data
131+
curl_args+=( "${BASE_URL}" \
132+
--data-urlencode "page=${PAGE}" \
133+
--data-urlencode "onlyActiveTags=true" \
134+
--data-urlencode "filter_tag_name=like:${FILTER}" \
135+
)
136+
137+
RESPONSE=$(curl "${curl_args[@]}")
138+
139+
# check for errors in the response:
140+
if [[ "$(jq -r '.error' <<<"$RESPONSE")" != "null" ]]; then
141+
echo "❌ Error fetching tags: $(jq -r '.error' <<<"$RESPONSE")"
142+
exit 1
143+
fi
144+
# pull matching lines into a variable:
145+
mapfile -t LINES < <(
146+
echo "$RESPONSE" |
147+
jq -r --arg f "$FILTER" '
148+
.tags[]
149+
| "\(.name)|\(.last_modified)"
150+
'
151+
)
152+
153+
[[ "$(jq -r '.has_additional' <<<"$RESPONSE")" == "true" ]] \
154+
&& ((PAGE++)) || break
155+
done
156+
157+
# ---------------------------
158+
# Process each matching tag
159+
# ---------------------------
160+
for LINE in "${LINES[@]}"; do
161+
IFS="|" read -r tag last_modified <<< "$LINE"
162+
last_sec=$(date -d "$last_modified" +%s 2>/dev/null || echo 0)
163+
if [[ "$last_sec" -lt "$CUTOFF" ]]; then
164+
# add to the count of found tags, incrementing safely:
165+
: $((FOUND++))
166+
fmt=$(date -d "$last_modified" +"%Y-%m-%d %H:%M:%S %Z")
167+
if $DRY_RUN; then
168+
printf "%-80s %s 💡 would be deleted\n" "$tag" "$fmt"
169+
else
170+
if skopeo delete "docker://quay.io/${REPO}:${tag}" &> /dev/null; then
171+
printf "%-80s %s ✅ deleted\n" "$tag" "$fmt"
172+
# add to the count of deleted tags, incrementing safely:
173+
: $((DELETED++))
174+
else
175+
printf "%-80s %s ❌ failed\n" "$tag" "$fmt"
176+
fi
177+
fi
178+
fi
179+
done
180+
181+
echo ""
182+
# ---------------------------
183+
# Summary output
184+
# ---------------------------
185+
if $DRY_RUN; then
186+
echo "🧪 Dry-run: would delete $FOUND tags matching “$FILTER"
187+
else
188+
echo "🔢 Total matching tags found: $FOUND"
189+
echo "✅ Total tags deleted: $DELETED"
190+
fi

0 commit comments

Comments
 (0)