Skip to content

Commit

Permalink
Merge pull request #12 from Emilgardis/include-only
Browse files Browse the repository at this point in the history
implement filter-tags
  • Loading branch information
sondrelg authored Jan 5, 2022
2 parents e6ef320 + 0dfe4a6 commit 6f3e87f
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 4 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,22 @@ Restrict deletions to images without specific tags, if specified.

Supports Unix-shell style wildcards, i.e 'v*' to match all tags starting with 'v'.

## filter-tags

* **Required**: `No`
* **Example**: `sha-*`

Comma-separated list of tags to consider for deletion.

Supports Unix-shell style wildcards, i.e 'sha-*' to match all tags starting with 'sha-'.

## filter-include-untagged

* **Required**: `No`
* **Default**: `true`

Whether to consider untagged images for deletion.

# Nice to knows

* The GitHub API restricts us to fetching 100 image versions per image name, so if your registry isn't 100% clean after
Expand Down
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ inputs:
description: 'How many images to keep no matter what. Defaults to 0 which means you might delete everything'
required: false
default: '0'
filter-tags:
description: "Comma-separated list of tags to consider for deletion. Supports Unix-shell style wildcards"
required: false
filter-include-untagged:
description: "Whether to consider untagged images for deletion."
required: false

runs:
using: 'docker'
Expand All @@ -49,3 +55,5 @@ runs:
- ${{ inputs.untagged-only }}
- ${{ inputs.skip-tags }}
- ${{ inputs.keep-at-least }}
- ${{ inputs.filter-tags }}
- ${{ inputs.filter-include-untagged }}
41 changes: 38 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from dataclasses import dataclass
from distutils.util import strtobool
from enum import Enum
from fnmatch import fnmatch
from functools import partial
from sys import argv
from typing import TYPE_CHECKING
from urllib.parse import quote_from_bytes
from fnmatch import fnmatch

from dateparser import parse
from httpx import AsyncClient
Expand Down Expand Up @@ -122,6 +122,8 @@ class Inputs:
untagged_only: bool
skip_tags: list[str]
keep_at_least: int
filter_tags: list[str]
filter_include_untagged: bool
org_name: Optional[str] = None

def __post_init__(self) -> None:
Expand Down Expand Up @@ -186,7 +188,18 @@ async def get_and_delete_old_versions(image_name: ImageName, inputs: Inputs, htt
# Skipping because no tagged images should be deleted
continue

# if untagged and we don't include untagged images, skip this image.
if not image_tags and not inputs.filter_include_untagged:
continue

skip = False
for filter_tag in inputs.filter_tags:
if not any(fnmatch(tag, filter_tag) for tag in image_tags):
# no image tag matches the filter tag, skip this image
skip = True
if skip:
break

for skip_tag in inputs.skip_tags:
if any(fnmatch(tag, skip_tag) for tag in image_tags):
# Skipping because this image version is tagged with a protected tag
Expand All @@ -210,6 +223,8 @@ def validate_inputs(
untagged_only: Union[bool, str],
skip_tags: Optional[str],
keep_at_least: Optional[str],
filter_tags: Optional[str],
filter_include_untagged: Union[bool, str],
) -> Inputs:
"""
Perform basic validation on the incoming parameters and return an Inputs instance.
Expand Down Expand Up @@ -241,6 +256,16 @@ def validate_inputs(
if keep_at_least_ < 0:
raise ValueError('keep-at-least must be 0 or positive')

if filter_tags is None:
filter_tags_ = []
else:
filter_tags_ = [i.strip() for i in filter_tags.split(',')]

if isinstance(filter_include_untagged, str):
filter_include_untagged_ = strtobool(filter_include_untagged) == 1
else:
filter_include_untagged_ = filter_include_untagged

return Inputs(
parsed_cutoff=parsed_cutoff,
timestamp_type=TimestampType(timestamp_type),
Expand All @@ -249,6 +274,8 @@ def validate_inputs(
untagged_only=untagged_only_,
skip_tags=skip_tags_,
keep_at_least=keep_at_least_,
filter_tags=filter_tags_,
filter_include_untagged=filter_include_untagged_,
)


Expand All @@ -274,6 +301,8 @@ async def main(
untagged_only: Union[bool, str] = False,
skip_tags: Optional[str] = None,
keep_at_least: Optional[str] = None,
filter_tags: Optional[str] = None,
filter_include_untagged: Union[bool, str] = True,
) -> None:
"""
Delete old image versions.
Expand All @@ -289,11 +318,17 @@ async def main(
Must contain a reference to the timezone.
:param token: The personal access token to authenticate with.
:param untagged_only: Whether to only delete untagged images.
:param skip_tags: Comma-separated list of tags to not delete. Supports wildcard '*', '?', '[seq]' and '[!seq]' via Unix shell-style wildcards
:param skip_tags: Comma-separated list of tags to not delete.
Supports wildcard '*', '?', '[seq]' and '[!seq]' via Unix shell-style wildcards
:param keep_at_least: Number of images to always keep
:param filter_tags: Comma-separated list of tags to consider for deletion.
Supports wildcard '*', '?', '[seq]' and '[!seq]' via Unix shell-style wildcards
:param filter_include_untagged: Whether to consider untagged images for deletion.
"""
parsed_image_names: list[ImageName] = parse_image_names(image_names)
inputs: Inputs = validate_inputs(account_type, org_name, timestamp_type, cut_off, untagged_only, skip_tags, keep_at_least)
inputs: Inputs = validate_inputs(
account_type, org_name, timestamp_type, cut_off, untagged_only, skip_tags, keep_at_least, filter_tags, filter_include_untagged
)
headers = {'accept': 'application/vnd.github.v3+json', 'Authorization': f'Bearer {token}'}

async with AsyncClient(headers=headers) as http_client:
Expand Down
33 changes: 33 additions & 0 deletions main_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ def test_inputs_dataclass():
untagged_only=False,
skip_tags=[],
keep_at_least=0,
filter_tags=[],
filter_include_untagged=True,
)
assert personal.is_org is False
assert personal.list_package_versions == list_package_versions
Expand All @@ -86,6 +88,8 @@ def test_inputs_dataclass():
untagged_only=False,
skip_tags=[],
keep_at_least=0,
filter_tags=[],
filter_include_untagged=True,
)
assert org.is_org is True
assert isinstance(org.list_package_versions, partial)
Expand Down Expand Up @@ -132,6 +136,8 @@ class TestGetAndDeleteOldVersions:
'untagged_only': False,
'skip_tags': [],
'keep_at_least': '0',
'filter_tags': [],
'filter_include_untagged': True,
}

@staticmethod
Expand Down Expand Up @@ -241,6 +247,19 @@ async def test_untagged_only(self, capsys):
captured = capsys.readouterr()
assert captured.out == 'No more versions to delete for a\n'

@pytest.mark.asyncio
async def test_filter_tags(self, capsys):
data = deepcopy(self.valid_data)
data[0]['metadata'] = {'container': {'tags': ['sha-deadbeef', 'edge']}}

Inputs.list_package_versions = partial(self._mock_list_package_versions, data)
inputs = Inputs(**self.valid_inputs | {'filter_tags': ['sha-*']})

await get_and_delete_old_versions(image_name=ImageName('a', 'a'), inputs=inputs, http_client=mock_http_client)

captured = capsys.readouterr()
assert captured.out == 'Deleted old image: a:1234567\n'


def test_inputs_bad_account_type():
defaults = {
Expand All @@ -251,6 +270,8 @@ def test_inputs_bad_account_type():
'untagged_only': False,
'skip_tags': None,
'keep_at_least': 0,
'filter_tags': None,
'filter_include_untagged': True,
}

# Account type
Expand Down Expand Up @@ -294,6 +315,18 @@ def test_inputs_bad_account_type():
with pytest.raises(ValueError, match='keep-at-least must be 0 or positive'):
validate_inputs(**defaults | {'keep_at_least': '-1'})

# Filter tags
assert validate_inputs(**defaults | {'filter_tags': 'a'}).filter_tags == ['a']
assert validate_inputs(**defaults | {'filter_tags': 'sha-*,latest'}).filter_tags == ['sha-*', 'latest']
assert validate_inputs(**defaults | {'filter_tags': 'sha-* , latest'}).filter_tags == ['sha-*', 'latest']

# Filter include untagged
for i in ['true', 'True', '1']:
assert validate_inputs(**defaults | {'filter_include_untagged': i}).filter_include_untagged is True
for j in ['False', 'false', '0']:
assert validate_inputs(**defaults | {'filter_include_untagged': j}).filter_include_untagged is False
assert validate_inputs(**defaults | {'filter_include_untagged': False}).filter_include_untagged is False


def test_parse_image_names():
assert parse_image_names('a') == [ImageName('a', 'a')]
Expand Down
99 changes: 98 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pytest-mock = "^3.6.1"
pytest-asyncio = "^0.15.1"
pytest-cov = "^2.12.1"
coverage = {extras = ["toml"], version = "^6.0.2"}
black = {version = "^21.12b0", allow-prereleases = true}

[build-system]
requires = ["poetry-core>=1.0.0"]
Expand Down

0 comments on commit 6f3e87f

Please sign in to comment.