diff --git a/flake.nix b/flake.nix index 0539a59..f5023fa 100644 --- a/flake.nix +++ b/flake.nix @@ -43,27 +43,33 @@ ); # Packages: nix build .#pkgmgr / .#default - packages = forAllSystems (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - python = pkgs.python311; - pypkgs = pkgs.python311Packages; + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + python = pkgs.python311; + pypkgs = pkgs.python311Packages; - pkgmgrPkg = pypkgs.buildPythonApplication { - pname = "package-manager"; - version = "0.1.0"; - src = ./.; + # Be robust: ansible-core if available, otherwise ansible. + ansiblePkg = + if pkgs ? ansible-core then pkgs.ansible-core + else pkgs.ansible; + in + rec { + pkgmgr = pypkgs.buildPythonApplication { + pname = "package-manager"; + version = "0.1.0"; + src = ./.; - propagatedBuildInputs = [ - pypkgs.pyyaml - # add further dependencies here - ]; - }; - in { - pkgmgr = pkgmgrPkg; - default = pkgmgrPkg; - } - ); + propagatedBuildInputs = [ + pypkgs.pyyaml + ansiblePkg + ]; + }; + + # default package just points to pkgmgr + default = pkgmgr; + } + ); # Apps: nix run .#pkgmgr / .#default apps = forAllSystems (system: diff --git a/pkgmgr/installers/ansible_requirements.py b/pkgmgr/installers/ansible_requirements.py index 71e2f91..606767e 100644 --- a/pkgmgr/installers/ansible_requirements.py +++ b/pkgmgr/installers/ansible_requirements.py @@ -9,7 +9,7 @@ This installer installs collections and roles via ansible-galaxy when found. import os import tempfile -from typing import Any, Dict +from typing import Any, Dict, List import yaml @@ -35,12 +35,79 @@ class AnsibleRequirementsInstaller(BaseInstaller): print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}") return {} + def _validate_requirements(self, requirements: Dict[str, Any], identifier: str) -> None: + """ + Validate the requirements.yml structure. + Raises SystemExit on any validation error. + """ + + errors: List[str] = [] + + if not isinstance(requirements, dict): + errors.append("Top-level structure must be a mapping.") + + else: + allowed_keys = {"collections", "roles"} + unknown_keys = set(requirements.keys()) - allowed_keys + if unknown_keys: + print( + f"Warning: requirements.yml in {identifier} contains unknown keys: " + f"{', '.join(sorted(unknown_keys))}" + ) + + for section in ("collections", "roles"): + if section not in requirements: + continue + + value = requirements[section] + if not isinstance(value, list): + errors.append(f"'{section}' must be a list.") + continue + + for idx, entry in enumerate(value): + if isinstance(entry, str): + # short form "community.docker" etc. + continue + + if isinstance(entry, dict): + # Collections: brauchen zwingend 'name' + if section == "collections": + if not entry.get("name"): + errors.append( + f"Entry #{idx} in '{section}' is a mapping " + f"but has no 'name' key." + ) + else: + # Roles: 'name' ODER 'src' sind ok (beides gängig) + if not (entry.get("name") or entry.get("src")): + errors.append( + f"Entry #{idx} in '{section}' is a mapping but " + f"has neither 'name' nor 'src' key." + ) + continue + + errors.append( + f"Entry #{idx} in '{section}' has invalid type " + f"{type(entry).__name__}; expected string or mapping." + ) + + if errors: + print(f"Invalid requirements.yml in {identifier}:") + for err in errors: + print(f" - {err}") + raise SystemExit( + f"requirements.yml validation failed for {identifier}." + ) + def run(self, ctx: RepoContext) -> None: req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE) requirements = self._load_requirements(req_file, ctx.identifier) - if not requirements or not isinstance(requirements, dict): + if not requirements: return + # Validate structure before doing anything dangerous + self._validate_requirements(requirements, ctx.identifier) + if "collections" not in requirements and "roles" not in requirements: return diff --git a/tests/unit/pkgmgr/installers/test_ansible_requirements.py b/tests/unit/pkgmgr/installers/test_ansible_requirements.py index cf087d9..174329e 100644 --- a/tests/unit/pkgmgr/installers/test_ansible_requirements.py +++ b/tests/unit/pkgmgr/installers/test_ansible_requirements.py @@ -66,6 +66,103 @@ roles: cmds, ) + # --- Neue Tests für den Validator ------------------------------------- + + @patch("pkgmgr.installers.ansible_requirements.run_command") + @patch( + "builtins.open", + new_callable=mock_open, + read_data=""" +- not: + - a: mapping +""", + ) + @patch("os.path.exists", return_value=True) + def test_run_raises_when_top_level_is_not_mapping( + self, mock_exists, mock_file, mock_run_command + ): + # YAML ist eine Liste -> Validator soll fehlschlagen + with self.assertRaises(SystemExit): + self.installer.run(self.ctx) + + mock_run_command.assert_not_called() + + @patch("pkgmgr.installers.ansible_requirements.run_command") + @patch( + "builtins.open", + new_callable=mock_open, + read_data=""" +collections: community.docker +roles: + - src: geerlingguy.docker +""", + ) + @patch("os.path.exists", return_value=True) + def test_run_raises_when_collections_is_not_list( + self, mock_exists, mock_file, mock_run_command + ): + # collections ist ein String statt Liste -> invalid + with self.assertRaises(SystemExit): + self.installer.run(self.ctx) + + mock_run_command.assert_not_called() + + @patch("pkgmgr.installers.ansible_requirements.run_command") + @patch( + "builtins.open", + new_callable=mock_open, + read_data=""" +collections: + - name: community.docker +roles: + - version: "latest" +""", + ) + @patch("os.path.exists", return_value=True) + def test_run_raises_when_role_mapping_has_no_name( + self, mock_exists, mock_file, mock_run_command + ): + # roles-Eintrag ist Mapping ohne 'name' -> invalid + with self.assertRaises(SystemExit): + self.installer.run(self.ctx) + + mock_run_command.assert_not_called() + + @patch("pkgmgr.installers.ansible_requirements.run_command") + @patch("tempfile.NamedTemporaryFile") + @patch( + "builtins.open", + new_callable=mock_open, + read_data=""" +collections: + - name: community.docker +extra_key: should_be_ignored_but_warned +""", + ) + @patch("os.path.exists", return_value=True) + def test_run_accepts_unknown_top_level_keys( + self, mock_exists, mock_file, mock_tmp, mock_run_command + ): + """ + Unknown top-level keys (z.B. 'extra_key') sollen nur eine Warnung + auslösen, aber keine Validation-Exception. + """ + mock_tmp().__enter__().name = "/tmp/req.yml" + + # Erwartung: kein SystemExit, run_command wird für collections aufgerufen + self.installer.run(self.ctx) + + cmds = [call[0][0] for call in mock_run_command.call_args_list] + self.assertIn( + "ansible-galaxy collection install -r /tmp/req.yml", + cmds, + ) + # Keine roles definiert -> kein role-install + self.assertNotIn( + "ansible-galaxy role install -r /tmp/req.yml", + cmds, + ) + if __name__ == "__main__": unittest.main()