mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-12-16 13:53:05 +00:00
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:
@@ -83,6 +83,13 @@ class DefaultsGenerator:
|
|||||||
print(f"Error during rendering: {e}", file=sys.stderr)
|
print(f"Error during rendering: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Sort applications by application key for stable output
|
||||||
|
apps = result.get("defaults_applications", {})
|
||||||
|
if isinstance(apps, dict) and apps:
|
||||||
|
result["defaults_applications"] = {
|
||||||
|
k: apps[k] for k in sorted(apps.keys())
|
||||||
|
}
|
||||||
|
|
||||||
# Write output
|
# Write output
|
||||||
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with self.output_file.open("w", encoding="utf-8") as f:
|
with self.output_file.open("w", encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -220,6 +220,10 @@ def main():
|
|||||||
print(f"Error building user entries: {e}", file=sys.stderr)
|
print(f"Error building user entries: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Sort users by key for deterministic output
|
||||||
|
if isinstance(users, dict) and users:
|
||||||
|
users = OrderedDict(sorted(users.items()))
|
||||||
|
|
||||||
# Convert OrderedDict into plain dict for YAML
|
# Convert OrderedDict into plain dict for YAML
|
||||||
default_users = {'default_users': users}
|
default_users = {'default_users': users}
|
||||||
plain_data = dictify(default_users)
|
plain_data = dictify(default_users)
|
||||||
|
|||||||
@@ -108,6 +108,89 @@ class TestGenerateDefaultApplications(unittest.TestCase):
|
|||||||
self.assertIn("nocfgdirapp", apps)
|
self.assertIn("nocfgdirapp", apps)
|
||||||
self.assertEqual(apps["nocfgdirapp"], {})
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -132,5 +132,122 @@ class TestGenerateUsers(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user