feat(cli/build/defaults): ensure deterministic alphabetical sorting for applications and users

- Added sorting by application key and user key before YAML output.
- Ensures stable and reproducible file generation across runs.
- Added comprehensive unit tests verifying key order and output stability.

See: https://chatgpt.com/share/68ef4778-a848-800f-a50b-a46a3b878797
This commit is contained in:
2025-10-15 09:04:39 +02:00
parent a80b26ed9e
commit e6f4f3a6a4
4 changed files with 211 additions and 0 deletions

View File

@@ -108,6 +108,89 @@ class TestGenerateDefaultApplications(unittest.TestCase):
self.assertIn("nocfgdirapp", apps)
self.assertEqual(apps["nocfgdirapp"], {})
def test_applications_sorted_by_key(self):
"""
Ensure that defaults_applications keys are written in alphabetical order.
"""
# Create several roles in non-sorted order
for name, cfg in [
("web-app-zeta", {"vars_id": "zeta", "cfg": "z: 1\n"}),
("web-app-alpha", {"vars_id": "alpha", "cfg": "a: 1\n"}),
("web-app-mu", {"vars_id": "mu", "cfg": "m: 1\n"}),
]:
role = self.roles_dir / name
(role / "vars").mkdir(parents=True, exist_ok=True)
(role / "config").mkdir(parents=True, exist_ok=True)
(role / "vars" / "main.yml").write_text(f"application_id: {cfg['vars_id']}\n")
(role / "config" / "main.yml").write_text(cfg["cfg"])
# Run generator
result = subprocess.run(
["python3", str(self.script_path),
"--roles-dir", str(self.roles_dir),
"--output-file", str(self.output_file)],
capture_output=True, text=True
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
# Validate order of keys in YAML
data = yaml.safe_load(self.output_file.read_text())
apps = data.get("defaults_applications", {})
# dict preserves insertion order in Python 3.7+, PyYAML keeps document order
keys_in_file = list(apps.keys())
self.assertEqual(
keys_in_file,
sorted(keys_in_file),
msg=f"Applications are not sorted: {keys_in_file}"
)
# Sanity: all expected apps present
for app in ("alpha", "mu", "zeta", "testapp"):
self.assertIn(app, apps)
def test_sorting_is_stable_across_runs(self):
"""
Running the generator multiple times yields identical content (stable sort).
"""
# Create a couple more roles (unsorted)
for name, appid in [
("web-app-beta", "beta"),
("web-app-delta", "delta"),
]:
role = self.roles_dir / name
(role / "vars").mkdir(parents=True, exist_ok=True)
(role / "config").mkdir(parents=True, exist_ok=True)
(role / "vars" / "main.yml").write_text(f"application_id: {appid}\n")
(role / "config" / "main.yml").write_text("key: value\n")
# First run
result1 = subprocess.run(
["python3", str(self.script_path),
"--roles-dir", str(self.roles_dir),
"--output-file", str(self.output_file)],
capture_output=True, text=True
)
self.assertEqual(result1.returncode, 0, msg=result1.stderr)
content_run1 = self.output_file.read_text()
# Second run (simulate potential filesystem order differences by touching dirs)
for p in self.roles_dir.iterdir():
os.utime(p, None)
result2 = subprocess.run(
["python3", str(self.script_path),
"--roles-dir", str(self.roles_dir),
"--output-file", str(self.output_file)],
capture_output=True, text=True
)
self.assertEqual(result2.returncode, 0, msg=result2.stderr)
content_run2 = self.output_file.read_text()
self.assertEqual(
content_run1, content_run2,
msg="Output differs between runs; sorting should be stable."
)
if __name__ == "__main__":
unittest.main()

View File

@@ -132,5 +132,122 @@ class TestGenerateUsers(unittest.TestCase):
finally:
shutil.rmtree(tmp)
def test_cli_users_sorted_by_key(self):
"""
Ensure that default_users keys are written in alphabetical order.
"""
import tempfile
import subprocess
from pathlib import Path
tmpdir = Path(tempfile.mkdtemp())
try:
roles_dir = tmpdir / "roles"
roles_dir.mkdir()
# Create multiple roles with users in unsorted order
for role, users_map in [
("role-zeta", {"zeta": {"email": "z@ex"}}),
("role-alpha", {"alpha": {"email": "a@ex"}}),
("role-mu", {"mu": {"email": "m@ex"}}),
("role-beta", {"beta": {"email": "b@ex"}}),
]:
(roles_dir / role / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / role / "users" / "main.yml", "w") as f:
yaml.safe_dump({"users": users_map}, f)
out_file = tmpdir / "users.yml"
# Resolve script path like in other tests (relative to repo root)
script_path = Path(__file__).resolve().parents[5] / "cli" / "build" / "defaults" / "users.py"
# Run generator
result = subprocess.run(
["python3", str(script_path),
"--roles-dir", str(roles_dir),
"--output", str(out_file)],
capture_output=True, text=True
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertTrue(out_file.exists(), "Output file was not created.")
data = yaml.safe_load(out_file.read_text())
self.assertIn("default_users", data)
users_map = data["default_users"]
keys_in_file = list(users_map.keys())
# Expect alphabetical order
self.assertEqual(
keys_in_file, sorted(keys_in_file),
msg=f"Users are not sorted alphabetically: {keys_in_file}"
)
# Sanity: all expected keys present
for k in ["alpha", "beta", "mu", "zeta"]:
self.assertIn(k, users_map)
finally:
shutil.rmtree(tmpdir)
def test_cli_users_sorting_stable_across_runs(self):
"""
Running the generator multiple times yields identical content (stable sort).
"""
import tempfile
import subprocess
from pathlib import Path
tmpdir = Path(tempfile.mkdtemp())
try:
roles_dir = tmpdir / "roles"
roles_dir.mkdir()
# Unsorted creation order on purpose
cases = [
("role-d", {"duser": {"email": "d@ex"}}),
("role-a", {"auser": {"email": "a@ex"}}),
("role-c", {"cuser": {"email": "c@ex"}}),
("role-b", {"buser": {"email": "b@ex"}}),
]
for role, users_map in cases:
(roles_dir / role / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / role / "users" / "main.yml", "w") as f:
yaml.safe_dump({"users": users_map}, f)
out_file = tmpdir / "users.yml"
script_path = Path(__file__).resolve().parents[5] / "cli" / "build" / "defaults" / "users.py"
# First run
r1 = subprocess.run(
["python3", str(script_path),
"--roles-dir", str(roles_dir),
"--output", str(out_file)],
capture_output=True, text=True
)
self.assertEqual(r1.returncode, 0, msg=r1.stderr)
content1 = out_file.read_text()
# Touch dirs to shuffle filesystem mtimes
for p in roles_dir.iterdir():
os.utime(p, None)
# Second run
r2 = subprocess.run(
["python3", str(script_path),
"--roles-dir", str(roles_dir),
"--output", str(out_file)],
capture_output=True, text=True
)
self.assertEqual(r2.returncode, 0, msg=r2.stderr)
content2 = out_file.read_text()
self.assertEqual(
content1, content2,
msg="Output differs between runs; user sorting should be stable."
)
finally:
shutil.rmtree(tmpdir)
if __name__ == '__main__':
unittest.main()