diff --git a/README.md b/README.md index 5952706..f0e6cd6 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,113 @@ # DigitalOcean Registry Cleanup Action -This action deletes tags older than a specified number of days from a -DigitalOcean container registry, excluding the "latest" tag. Before using this -action, ensure you've set up `doctl` and authenticated with DigitalOcean. +This action helps manage tags in a DigitalOcean container registry by providing flexible cleanup options. It can delete tags based on age and/or keep a specific number of recent tags. The action always preserves the "latest" tag. + +## Features + +- Delete tags older than a specified number of days +- Keep a specified number of most recent tags +- Safety check to prevent accidental deletion of all images +- Dry run mode to preview changes +- Preserves the "latest" tag ## Inputs ### `repository_name` +**Required**. Name of the DigitalOcean container registry repository. -Name of the DigitalOcean container registry repository. Required. - -### `dry_run` +### `days` +Number of days. Tags older than these many days will be deleted. +- Default: "2" +- Optional -If set to true, it will display tags to be deleted without actually deleting -them. Default is "false". +### `keep_last` +Number of most recent tags to keep, regardless of age. +- Optional +- Example: "3" will keep the three most recent tags -### `days` +### `dry_run` +If set to true, shows what would be deleted without making any changes. +- Default: "false" +- Optional -Number of days. Tags older than these many days will be deleted. Default is "2". +### `bypass_safety` +Bypass the safety check that prevents deletion when no recent images exist. +- Default: "false" +- Optional -## Example usage +## Usage Examples -```yml +### Basic Usage +```yaml - name: Install doctl uses: digitalocean/action-doctl@v2 with: token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} -- name: Log in to DigitalOcean Container Registry with short-lived credentials +- name: Log in to DigitalOcean Container Registry run: doctl registry login --expiry-seconds 1200 -- uses: raisedadead/action-docr-cleanup@v1 +- name: Cleanup Registry + uses: raisedadead/action-docr-cleanup@v1 with: repository_name: 'your-repository-name' - dry_run: 'true' days: '7' ``` -## Manual run: +### Keep Recent Tags +```yaml +- uses: raisedadead/action-docr-cleanup@v1 + with: + repository_name: 'your-repository-name' + days: '7' + keep_last: '3' # Keep 3 most recent tags +``` -Assuming you have -[doctl installed](https://docs.digitalocean.com/reference/doctl/) and -authenticated with your user, you can run the following commands: +### Dry Run Mode +```yaml +- uses: raisedadead/action-docr-cleanup@v1 + with: + repository_name: 'your-repository-name' + days: '7' + dry_run: 'true' # Preview changes without deleting +``` -```bash -doctl registry login --expiry-seconds 1200 +### Bypass Safety Check +```yaml +- uses: raisedadead/action-docr-cleanup@v1 + with: + repository_name: 'your-repository-name' + days: '7' + bypass_safety: 'true' # Disable safety checks ``` -For help on the script: +## Manual Usage + +You can also run the script directly after installing [doctl](https://docs.digitalocean.com/reference/doctl/) and authenticating: ```bash -./entrypoint.sh -h -``` +# Login to registry +doctl registry login --expiry-seconds 1200 -To run the script in dry-run mode: +# View help +./entrypoint.sh -h -```bash -./entrypoint.sh -d -n -``` +# Dry run with 7-day threshold +./entrypoint.sh -d -n 7 your-repository-name -To run the script: +# Keep 3 most recent tags, delete others older than 7 days +./entrypoint.sh -n 7 --keep-last 3 your-repository-name -```bash -./entrypoint.sh -n +# Bypass safety check +./entrypoint.sh -n 7 -b your-repository-name ``` -example: +## Safety Features -```bash -./entrypoint.sh -n 1 myapp -``` +1. The "latest" tag is always preserved +2. By default, the action won't delete tags if no images newer than the threshold exist +3. Dry run mode allows previewing changes before actual deletion ## License -Software: The software as it is licensed under the [MIT](LICENSE) License, -please feel free to extend, re-use, share the code. +Licensed under the [MIT](LICENSE) License. Feel free to extend, reuse, and share. diff --git a/action.yml b/action.yml index f2e0309..79f9d34 100644 --- a/action.yml +++ b/action.yml @@ -1,24 +1,37 @@ -name: 'DigitalOcean Registry Cleanup Action' -description: 'Deletes tags older than a specified number of days from a DigitalOcean container registry' -author: 'Mrugesh Mohapatra' +name: "DigitalOcean Registry Cleanup Action" +description: "Deletes tags from a DigitalOcean container registry based on age and count criteria" +author: "Mrugesh Mohapatra" branding: - icon: 'trash' - color: 'red' + icon: "trash" + color: "red" inputs: repository_name: - description: 'Name of the DigitalOcean container registry repository' + description: "Name of the DigitalOcean container registry repository" required: true dry_run: - description: 'If set to true, it will display tags to be deleted without actually deleting them' + description: "If true, shows what would be deleted without making any changes" required: false - default: 'false' + default: "false" days: - description: 'Number of days. Tags older than these many days will be deleted' + description: "Delete tags older than these many days" + required: false + default: "2" + bypass_safety: + description: "Bypass the safety check that prevents deletion when no recent images exist" + required: false + default: "false" + keep_last: + description: "Number of most recent tags to keep, regardless of age" required: false - default: '2' runs: - using: 'composite' + using: "composite" steps: - - run: $GITHUB_ACTION_PATH/entrypoint.sh ${{ inputs.dry_run == 'true' && '-d' || '' }} -n ${{ inputs.days }} ${{ inputs.repository_name }} + - run: | + $GITHUB_ACTION_PATH/entrypoint.sh \ + ${{ inputs.dry_run == 'true' && '-d' || '' }} \ + -n ${{ inputs.days }} \ + ${{ inputs.bypass_safety == 'true' && '-b' || '' }} \ + ${{ inputs.keep_last && format('--keep-last {0}', inputs.keep_last) || '' }} \ + ${{ inputs.repository_name }} shell: bash diff --git a/entrypoint.sh b/entrypoint.sh index 676354d..ad1da26 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,12 +1,13 @@ #!/bin/bash usage() { - echo "Usage: $0 [-d] [-n days] [-b] repository_name" - echo " -d Dry run. Show tags to be deleted without actually deleting them." - echo " -n days Number of days. Tags older than these many days will be deleted." - echo " -b Bypass the check for the number of images within the threshold." - echo " -h Display this help message." - echo " DEBUG=true Enable debug mode." + echo "Usage: $0 [-d] [-n days] [-b] [--keep-last count] repository_name" + echo " -d Dry run. Show tags to be deleted without actually deleting them." + echo " -n days Number of days. Tags older than these many days will be deleted." + echo " -b Bypass the check for the number of images within the threshold." + echo " --keep-last n Keep the n most recent images, regardless of age." + echo " -h Display this help message." + echo " DEBUG=true Enable debug mode." } check_dependencies() { @@ -58,7 +59,13 @@ count_images_within_threshold() { # Function to delete tags delete_tags() { + local count=0 for tag in "${toDelete[@]}"; do + if [ -n "$MAX_DELETE" ] && [ $count -ge "$MAX_DELETE" ]; then + echo "Reached maximum deletion count of $MAX_DELETE" + break + fi + if [ "$DRY_RUN" = true ]; then echo "Would delete tag (dry run): $tag" else @@ -67,40 +74,60 @@ delete_tags() { echo "Failed to delete tag: $tag" fi fi + count=$((count + 1)) done + + if [ -n "$MAX_DELETE" ]; then + echo "Deleted/Would delete $count out of maximum $MAX_DELETE requested tags" + fi } DRY_RUN=false DAYS=2 BYPASS_CHECK=false - -while getopts ":dh:n:b" opt; do - case $opt in - d) DRY_RUN=true ;; - n) DAYS=$OPTARG ;; - b) BYPASS_CHECK=true ;; - h) +KEEP_LAST="" + +# Modified to handle long options +while [[ $# -gt 0 ]]; do + case $1 in + -d) + DRY_RUN=true + shift + ;; + -n) + DAYS="$2" + shift 2 + ;; + -b) + BYPASS_CHECK=true + shift + ;; + --keep-last) + KEEP_LAST="$2" + shift 2 + ;; + -h) usage exit 0 ;; - \?) - echo "Invalid option: -$OPTARG" >&2 + -*) + echo "Invalid option: $1" >&2 usage exit 1 ;; + *) + REPOSITORY="$1" + shift + ;; esac done -shift $((OPTIND - 1)) - -if [ "$#" -lt 1 ]; then +if [ -z "$REPOSITORY" ]; then echo "Error: Repository name is required." usage exit 1 fi -REPOSITORY="$1" - check_dependencies rawResponse=$(doctl registry repository list-tags "$REPOSITORY" --output=json) @@ -109,43 +136,141 @@ rawResponse=$(doctl registry repository list-tags "$REPOSITORY" --output=json) check_for_errors "$rawResponse" -tags=$(jq '[.[] | {updated_at, tag}]' <<<"$rawResponse") +# Get all non-latest tags immediately +tags=$(jq '[.[] | select(.tag != "latest") | {updated_at, tag}]' <<<"$rawResponse") toDelete=() len=$(jq length <<<"$tags") -echo "Found $len tags." +echo "Repository: $REPOSITORY" +echo "Total tags found: $len (excluding 'latest')" +echo "Parameters:" +echo " • Days threshold: $DAYS" +if [ -n "$KEEP_LAST" ]; then + echo " • Keep last: $KEEP_LAST" +fi +echo " • Dry run: $DRY_RUN" +echo " • Safety check: $([ "$BYPASS_CHECK" = true ] && echo "bypassed" || echo "enabled")" +echo "" # Check the number of images within the threshold if [ "$BYPASS_CHECK" = false ]; then image_count_within_threshold=$(count_images_within_threshold) if [ "$image_count_within_threshold" -le 1 ]; then - echo "Warning: Only $image_count_within_threshold image(s) found within the threshold of $DAYS days. Aborting deletion." + echo "WARNING: Safety check failed" + echo " • No images found newer than $DAYS day(s)" exit 1 fi fi -for i in $(seq 0 $((len - 1))); do - tag=$(jq -r --argjson index "$i" '.[$index].tag' <<<"$tags") - updated=$(jq -r --argjson index "$i" '.[$index].updated_at' <<<"$tags") - - if [[ "$OSTYPE" == "darwin"* ]]; then - updated=${updated/Z/+0000} - updatedDate=$(date -jf "%Y-%m-%dT%H:%M:%S%z" "$updated" +%s) - else - updatedDate=$(date -d "$updated" +%s) +# Sort tags by date (newest first) and apply keep-last logic +if [ -n "$KEEP_LAST" ]; then + # Convert to integer and validate + if ! [[ "$KEEP_LAST" =~ ^[0-9]+$ ]]; then + echo "Error: --keep-last value must be a positive integer" + exit 1 fi - now=$(date +%s) - diff=$((now - updatedDate)) - diff_days=$((diff / 86400)) + # Create a temporary array of all tags with timestamps + sorted_tags=() + + for i in $(seq 0 $((len - 1))); do + tag=$(jq -r --argjson index "$i" '.[$index].tag' <<<"$tags") + updated=$(jq -r --argjson index "$i" '.[$index].updated_at' <<<"$tags") + + # Convert date to timestamp for reliable sorting + if [[ "$OSTYPE" == "darwin"* ]]; then + updated=${updated/Z/+0000} + timestamp=$(date -jf "%Y-%m-%dT%H:%M:%S%z" "$updated" +%s) + else + timestamp=$(date -d "$updated" +%s) + fi + + # Store timestamp and tag together + sorted_tags+=("$timestamp:$tag") + done - if [ "$tag" != "latest" ] && [ $diff_days -ge $DAYS ]; then - toDelete+=("$tag") + # Sort timestamps in descending order + IFS=$'\n' sorted_tags=($(sort -t: -k1,1nr <<<"${sorted_tags[*]}")) + unset IFS + + echo "Operation:" + echo " • Strategy: Keep $KEEP_LAST most recent tags" + echo " • Total tags found: ${#sorted_tags[@]}" + + # Debug: Show which tags we're keeping + if [ "$DEBUG" = true ]; then + echo " • Tags to keep:" + for i in $(seq 0 $((KEEP_LAST - 1))); do + if [ "$i" -lt "${#sorted_tags[@]}" ]; then + tag="${sorted_tags[$i]#*:}" + echo " - $tag" + fi + done fi -done -echo "Found ${#toDelete[@]} tags to delete." + # Process tags for deletion + toDelete=() + for i in "${!sorted_tags[@]}"; do + timestamp="${sorted_tags[$i]%%:*}" + tag="${sorted_tags[$i]#*:}" -delete_tags + # Skip the most recent KEEP_LAST tags + if [ "$i" -lt "$KEEP_LAST" ]; then + continue + fi + + # For remaining tags, check if they're old enough to delete + now=$(date +%s) + diff=$((now - timestamp)) + diff_days=$((diff / 86400)) + + if [ $diff_days -ge $DAYS ]; then + toDelete+=("$tag") + fi + done +else + echo "Operation:" + echo " • Strategy: Remove tags older than $DAYS days" + # Original logic for when --keep-last is not specified + for i in $(seq 0 $((len - 1))); do + tag=$(jq -r --argjson index "$i" '.[$index].tag' <<<"$tags") + updated=$(jq -r --argjson index "$i" '.[$index].updated_at' <<<"$tags") + + if [[ "$OSTYPE" == "darwin"* ]]; then + updated=${updated/Z/+0000} + updatedDate=$(date -jf "%Y-%m-%dT%H:%M:%S%z" "$updated" +%s) + else + updatedDate=$(date -d "$updated" +%s) + fi + + now=$(date +%s) + diff=$((now - updatedDate)) + diff_days=$((diff / 86400)) + + if [ $diff_days -ge $DAYS ]; then + toDelete+=("$tag") + fi + done +fi + +if [ ${#toDelete[@]} -eq 0 ]; then + echo "Result: No tags to delete" +else + echo "Result: Found ${#toDelete[@]} tag(s) to delete" + if [ "$DRY_RUN" = true ]; then + echo " [DRY RUN - No changes will be made]" + for tag in "${toDelete[@]}"; do + echo " • Would delete: $tag" + done + else + for tag in "${toDelete[@]}"; do + echo " • Deleting: $tag" + if ! doctl registry repository delete-tag "$REPOSITORY" "$tag" --force; then + echo " ERROR: Failed to delete tag: $tag" + fi + done + echo "Operation completed successfully" + fi +fi