"""Implementation of GalaxyRule.""" from __future__ import annotations import sys from functools import total_ordering from typing import TYPE_CHECKING, Any from ansiblelint.constants import LINE_NUMBER_KEY from ansiblelint.rules import AnsibleLintRule if TYPE_CHECKING: from ansiblelint.errors import MatchError from ansiblelint.file_utils import Lintable class GalaxyRule(AnsibleLintRule): """Rule for checking collection version is greater than 1.0.0 and checking for changelog.""" id = "galaxy" description = "Confirm via galaxy.yml file if collection version is greater than or equal to 1.0.0 and check for changelog." severity = "MEDIUM" tags = ["metadata"] version_added = "v6.11.0 (last update)" _ids = { "galaxy[tags]": "galaxy.yaml must have one of the required tags", "galaxy[no-changelog]": "No changelog found. Please add a changelog file. Refer to the galaxy.md file for more info.", "galaxy[version-missing]": "galaxy.yaml should have version tag.", "galaxy[version-incorrect]": "collection version should be greater than or equal to 1.0.0", "galaxy[no-runtime]": "meta/runtime.yml file not found.", } def matchplay(self, file: Lintable, data: dict[str, Any]) -> list[MatchError]: """Return matches found for a specific play (entry in playbook).""" if file.kind != "galaxy": # type: ignore[comparison-overlap] return [] # Defined by Automation Hub Team and Partner Engineering required_tag_list = [ "application", "cloud", "database", "infrastructure", "linux", "monitoring", "networking", "security", "storage", "tools", "windows", ] results = [] base_path = file.path.parent.resolve() changelog_found = 0 changelog_paths = [ base_path / "changelogs" / "changelog.yaml", base_path / "CHANGELOG.rst", base_path / "CHANGELOG.md", ] for path in changelog_paths: if path.is_file(): changelog_found = 1 galaxy_tag_list = data.get("tags", None) # Changelog Check - building off Galaxy rule as there is no current way to check # for a nonexistent file if not changelog_found: results.append( self.create_matcherror( message="No changelog found. Please add a changelog file. Refer to the galaxy.md file for more info.", tag="galaxy[no-changelog]", filename=file, ), ) # Checking if galaxy.yml contains one or more required tags for certification if not galaxy_tag_list or not any( tag in required_tag_list for tag in galaxy_tag_list ): results.append( self.create_matcherror( message=( f"galaxy.yaml must have one of the required tags: {required_tag_list}" ), tag="galaxy[tags]", filename=file, ), ) if "version" not in data: results.append( self.create_matcherror( message="galaxy.yaml should have version tag.", lineno=data[LINE_NUMBER_KEY], tag="galaxy[version-missing]", filename=file, ), ) return results # returning here as it does not make sense # to continue for version check below version = data.get("version") if Version(version) < Version("1.0.0"): results.append( self.create_matcherror( message="collection version should be greater than or equal to 1.0.0", # pylint: disable=protected-access lineno=version._line_number, # noqa: SLF001 tag="galaxy[version-incorrect]", filename=file, ), ) if not (base_path / "meta" / "runtime.yml").is_file(): results.append( self.create_matcherror( message="meta/runtime.yml file not found.", tag="galaxy[no-runtime]", filename=file, ), ) return results @total_ordering class Version: """Simple class to compare arbitrary versions.""" def __init__(self, version_string: str): """Construct a Version object.""" self.components = version_string.split(".") def __eq__(self, other: object) -> bool: """Implement equality comparison.""" try: other = _coerce(other) except NotImplementedError: return NotImplemented return self.components == other.components def __lt__(self, other: Version) -> bool: """Implement lower-than operation.""" other = _coerce(other) return self.components < other.components def _coerce(other: object) -> Version: if isinstance(other, str): other = Version(other) if isinstance(other, (int, float)): other = Version(str(other)) if isinstance(other, Version): return other msg = f"Unable to coerce object type {type(other)} to Version" raise NotImplementedError(msg) if "pytest" in sys.modules: import pytest from ansiblelint.rules import RulesCollection # pylint: disable=ungrouped-imports from ansiblelint.runner import Runner def test_galaxy_collection_version_positive() -> None: """Positive test for collection version in galaxy.""" collection = RulesCollection() collection.register(GalaxyRule()) success = "examples/collection/galaxy.yml" good_runner = Runner(success, rules=collection) assert [] == good_runner.run() def test_galaxy_collection_version_negative() -> None: """Negative test for collection version in galaxy.""" collection = RulesCollection() collection.register(GalaxyRule()) failure = "examples/meta/galaxy.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() assert len(errs) == 1 def test_galaxy_no_collection_version() -> None: """Test for no collection version in galaxy.""" collection = RulesCollection() collection.register(GalaxyRule()) failure = "examples/no_collection_version/galaxy.yml" bad_runner = Runner(failure, rules=collection) errs = bad_runner.run() assert len(errs) == 1 def test_version_class() -> None: """Test for version class.""" v = Version("1.0.0") assert v == Version("1.0.0") assert v != NotImplemented def test_coerce() -> None: """Test for _coerce function.""" assert _coerce("1.0") == Version("1.0") assert _coerce(1.0) == Version("1.0") expected = "Unable to coerce object type" with pytest.raises(NotImplementedError, match=expected): _coerce(type(Version)) @pytest.mark.parametrize( ("file", "expected"), ( pytest.param( "examples/galaxy_no_required_tags/fail/galaxy.yml", ["galaxy[tags]"], id="tags", ), pytest.param( "examples/galaxy_no_required_tags/pass/galaxy.yml", [], id="pass", ), pytest.param( "examples/collection/galaxy.yml", ["schema[galaxy]"], id="schema", ), pytest.param( "examples/no_changelog/galaxy.yml", ["galaxy[no-changelog]"], id="no-changelog", ), pytest.param( "examples/no_collection_version/galaxy.yml", ["schema[galaxy]", "galaxy[version-missing]"], id="no-collection-version", ), ), ) def test_galaxy_rule( default_rules_collection: RulesCollection, file: str, expected: list[str], ) -> None: """Validate that rule works as intended.""" results = Runner(file, rules=default_rules_collection).run() assert len(results) == len(expected) for index, result in enumerate(results): assert result.tag == expected[index]