Enforce Ansible availability via Nix and validate requirements.yml
- Add ansiblePkg as propagated dependency in flake.nix so ansible-galaxy is available on host - Introduce strict requirements.yml validator for AnsibleRequirementsInstaller - Accept roles entries with either 'name' or 'src' - Ensure run() always validates requirements before installing dependencies - Extend unit tests to cover valid, invalid and warning-only requirements.yml cases See: https://chatgpt.com/share/69332bc4-a128-800f-a69c-fdc24c4cc7fe
This commit is contained in:
44
flake.nix
44
flake.nix
@@ -43,27 +43,33 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
# Packages: nix build .#pkgmgr / .#default
|
# Packages: nix build .#pkgmgr / .#default
|
||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
python = pkgs.python311;
|
python = pkgs.python311;
|
||||||
pypkgs = pkgs.python311Packages;
|
pypkgs = pkgs.python311Packages;
|
||||||
|
|
||||||
pkgmgrPkg = pypkgs.buildPythonApplication {
|
# Be robust: ansible-core if available, otherwise ansible.
|
||||||
pname = "package-manager";
|
ansiblePkg =
|
||||||
version = "0.1.0";
|
if pkgs ? ansible-core then pkgs.ansible-core
|
||||||
src = ./.;
|
else pkgs.ansible;
|
||||||
|
in
|
||||||
|
rec {
|
||||||
|
pkgmgr = pypkgs.buildPythonApplication {
|
||||||
|
pname = "package-manager";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
propagatedBuildInputs = [
|
propagatedBuildInputs = [
|
||||||
pypkgs.pyyaml
|
pypkgs.pyyaml
|
||||||
# add further dependencies here
|
ansiblePkg
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
in {
|
|
||||||
pkgmgr = pkgmgrPkg;
|
# default package just points to pkgmgr
|
||||||
default = pkgmgrPkg;
|
default = pkgmgr;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
# Apps: nix run .#pkgmgr / .#default
|
# Apps: nix run .#pkgmgr / .#default
|
||||||
apps = forAllSystems (system:
|
apps = forAllSystems (system:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ This installer installs collections and roles via ansible-galaxy when found.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -35,12 +35,79 @@ class AnsibleRequirementsInstaller(BaseInstaller):
|
|||||||
print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}")
|
print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}")
|
||||||
return {}
|
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:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE)
|
req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE)
|
||||||
requirements = self._load_requirements(req_file, ctx.identifier)
|
requirements = self._load_requirements(req_file, ctx.identifier)
|
||||||
if not requirements or not isinstance(requirements, dict):
|
if not requirements:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Validate structure before doing anything dangerous
|
||||||
|
self._validate_requirements(requirements, ctx.identifier)
|
||||||
|
|
||||||
if "collections" not in requirements and "roles" not in requirements:
|
if "collections" not in requirements and "roles" not in requirements:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,103 @@ roles:
|
|||||||
cmds,
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user