2025-12-16 22:22:47 +01:00
parent d8361fe00a
commit 3123ac4a08
4 changed files with 117 additions and 106 deletions

View File

@@ -1,7 +1,7 @@
SHELL := /usr/bin/env bash SHELL := /usr/bin/env bash
VENV ?= .venv VENV ?= .venv
PYTHON := python3 PYTHON ?= python3
PIP := python3 -m pip PIP ?= $(PYTHON) -m pip
ROLES_DIR := ./roles ROLES_DIR := ./roles
APPLICATIONS_OUT := ./group_vars/all/04_applications.yml APPLICATIONS_OUT := ./group_vars/all/04_applications.yml
@@ -19,6 +19,9 @@ LINT_TESTS_DIR ?= tests/lint
UNIT_TESTS_DIR ?= tests/unit UNIT_TESTS_DIR ?= tests/unit
INTEGRATION_TESTS_DIR ?= tests/integration INTEGRATION_TESTS_DIR ?= tests/integration
# Ensure repo root is importable (so module_utils/, filter_plugins/ etc. work)
PYTHONPATH ?= .
# Compute extra users as before # Compute extra users as before
RESERVED_USERNAMES := $(shell \ RESERVED_USERNAMES := $(shell \
find $(ROLES_DIR) -maxdepth 1 -type d -printf '%f\n' \ find $(ROLES_DIR) -maxdepth 1 -type d -printf '%f\n' \
@@ -92,7 +95,10 @@ test-lint:
exit 0; \ exit 0; \
fi fi
@echo "🔎 Running lint tests (dir: $(LINT_TESTS_DIR), pattern: $(TEST_PATTERN))…" @echo "🔎 Running lint tests (dir: $(LINT_TESTS_DIR), pattern: $(TEST_PATTERN))…"
PYTHONPATH=. $(PYTHON) -m unittest discover -s $(LINT_TESTS_DIR) -p "$(TEST_PATTERN)" @PYTHONPATH="$(PYTHONPATH)" $(PYTHON) -m unittest discover \
-s "$(LINT_TESTS_DIR)" \
-p "$(TEST_PATTERN)" \
-t "$(PYTHONPATH)"
test-unit: test-unit:
@if [ ! -d "$(UNIT_TESTS_DIR)" ]; then \ @if [ ! -d "$(UNIT_TESTS_DIR)" ]; then \
@@ -100,7 +106,10 @@ test-unit:
exit 0; \ exit 0; \
fi fi
@echo "🧪 Running unit tests (dir: $(UNIT_TESTS_DIR), pattern: $(TEST_PATTERN))…" @echo "🧪 Running unit tests (dir: $(UNIT_TESTS_DIR), pattern: $(TEST_PATTERN))…"
PYTHONPATH=. $(PYTHON) -m unittest discover -s $(UNIT_TESTS_DIR) -p "$(TEST_PATTERN)" @PYTHONPATH="$(PYTHONPATH)" $(PYTHON) -m unittest discover \
-s "$(UNIT_TESTS_DIR)" \
-p "$(TEST_PATTERN)" \
-t "$(PYTHONPATH)"
test-integration: test-integration:
@if [ ! -d "$(INTEGRATION_TESTS_DIR)" ]; then \ @if [ ! -d "$(INTEGRATION_TESTS_DIR)" ]; then \
@@ -108,20 +117,23 @@ test-integration:
exit 0; \ exit 0; \
fi fi
@echo "🧪 Running integration tests (dir: $(INTEGRATION_TESTS_DIR), pattern: $(TEST_PATTERN))…" @echo "🧪 Running integration tests (dir: $(INTEGRATION_TESTS_DIR), pattern: $(TEST_PATTERN))…"
PYTHONPATH=. $(PYTHON) -m unittest discover -s $(INTEGRATION_TESTS_DIR) -p "$(TEST_PATTERN)" @PYTHONPATH="$(PYTHONPATH)" $(PYTHON) -m unittest discover \
-s "$(INTEGRATION_TESTS_DIR)" \
-p "$(TEST_PATTERN)" \
-t "$(PYTHONPATH)"
# Backwards compatible target (kept) # Backwards compatible target (kept)
test-messy: test-lint test-unit test-integration test-messy: test-lint test-unit test-integration
@echo "📑 Checking Ansible syntax…" @echo "📑 Checking Ansible syntax…"
ansible-playbook -i localhost, -c local $(foreach f,$(wildcard group_vars/all/*.yml),-e @$(f)) playbook.yml --syntax-check ansible-playbook -i localhost, -c local $(foreach f,$(wildcard group_vars/all/*.yml),-e @$(f)) playbook.yml --syntax-check
test: setup-clean test-messy test: clean setup test-messy
@echo "Full test with setup-clean before was executed." @echo "Full test (setup + tests) executed."
deps: deps:
@if [ ! -x "$(PYTHON)" ]; then \ @if [ ! -d "$(VENV)" ]; then \
echo "🐍 Creating virtualenv $(VENV)"; \ echo "🐍 Creating virtualenv $(VENV)"; \
python3 -m venv $(VENV); \ python3 -m venv "$(VENV)"; \
fi fi
@echo "📦 Installing Python dependencies" @echo "📦 Installing Python dependencies"
@$(PIP) install --upgrade pip setuptools wheel @$(PIP) install --upgrade pip setuptools wheel

View File

@@ -32,7 +32,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)
def test_script_generates_expected_yaml(self): def test_script_generates_expected_yaml(self):
script_path = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "cli/setup/applications.py" script_path = Path(__file__).resolve().parent.parent.parent.parent.parent / "cli/setup/applications.py"
result = subprocess.run( result = subprocess.run(
[ [

View File

@@ -1,59 +1,56 @@
import os
import sys
import unittest import unittest
import tempfile import tempfile
import shutil import shutil
import os
import yaml import yaml
from collections import OrderedDict from collections import OrderedDict
# Add cli/ to import path from cli.setup import users
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..", "cli/setup/")))
import users
class TestGenerateUsers(unittest.TestCase): class TestGenerateUsers(unittest.TestCase):
def test_build_users_auto_increment_and_overrides(self): def test_build_users_auto_increment_and_overrides(self):
defs = { defs = {
'alice': {}, "alice": {},
'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'}, "bob": {"uid": 2000, "email": "bob@custom.com", "description": "Custom user"},
'carol': {} "carol": {},
} }
build = users.build_users( build = users.build_users(
defs=defs, defs=defs,
primary_domain='example.com', primary_domain="example.com",
start_id=1001, start_id=1001,
become_pwd='pw' become_pwd="pw",
) )
# alice should get uid/gid 1001 # alice should get uid/gid 1001
self.assertEqual(build['alice']['uid'], 1001) self.assertEqual(build["alice"]["uid"], 1001)
self.assertEqual(build['alice']['gid'], 1001) self.assertEqual(build["alice"]["gid"], 1001)
self.assertEqual(build['alice']['email'], 'alice@example.com') self.assertEqual(build["alice"]["email"], "alice@example.com")
# bob overrides # bob overrides
self.assertEqual(build['bob']['uid'], 2000) self.assertEqual(build["bob"]["uid"], 2000)
self.assertEqual(build['bob']['gid'], 2000) self.assertEqual(build["bob"]["gid"], 2000)
self.assertEqual(build['bob']['email'], 'bob@custom.com') self.assertEqual(build["bob"]["email"], "bob@custom.com")
self.assertIn('description', build['bob']) self.assertIn("description", build["bob"])
# carol should get next free id = 1002 # carol should get next free id = 1002
self.assertEqual(build['carol']['uid'], 1002) self.assertEqual(build["carol"]["uid"], 1002)
self.assertEqual(build['carol']['gid'], 1002) self.assertEqual(build["carol"]["gid"], 1002)
def test_build_users_default_lookup_password(self): def test_build_users_default_lookup_password(self):
""" """
When no 'password' override is provided, When no 'password' override is provided,
the become_pwd lookup template string must be used as the password. the become_pwd lookup template string must be used as the password.
""" """
defs = {'frank': {}} defs = {"frank": {}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
build = users.build_users( build = users.build_users(
defs=defs, defs=defs,
primary_domain='example.com', primary_domain="example.com",
start_id=1001, start_id=1001,
become_pwd=lookup_template become_pwd=lookup_template,
) )
self.assertEqual( self.assertEqual(
build['frank']['password'], build["frank"]["password"],
lookup_template, lookup_template,
"The lookup template string was not correctly applied as the default password" "The lookup template string was not correctly applied as the default password",
) )
def test_build_users_override_password(self): def test_build_users_override_password(self):
@@ -61,72 +58,71 @@ class TestGenerateUsers(unittest.TestCase):
When a 'password' override is provided, When a 'password' override is provided,
that custom password must be used instead of become_pwd. that custom password must be used instead of become_pwd.
""" """
defs = {'eva': {'password': 'custompw'}} defs = {"eva": {"password": "custompw"}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
build = users.build_users( build = users.build_users(
defs=defs, defs=defs,
primary_domain='example.com', primary_domain="example.com",
start_id=1001, start_id=1001,
become_pwd=lookup_template become_pwd=lookup_template,
) )
self.assertEqual( self.assertEqual(
build['eva']['password'], build["eva"]["password"],
'custompw', "custompw",
"The override password was not correctly applied" "The override password was not correctly applied",
) )
def test_build_users_duplicate_override_uid(self): def test_build_users_duplicate_override_uid(self):
defs = { defs = {
'u1': {'uid': 1001}, "u1": {"uid": 1001},
'u2': {'uid': 1001} "u2": {"uid": 1001},
} }
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
users.build_users(defs, 'ex.com', 1001, 'pw') users.build_users(defs, "ex.com", 1001, "pw")
def test_build_users_shared_gid_allowed(self): def test_build_users_shared_gid_allowed(self):
# Allow two users to share the same GID when one overrides gid and the other uses that as uid # Allow two users to share the same GID when one overrides gid and the other uses that as uid
defs = { defs = {
'a': {'uid': 1500}, "a": {"uid": 1500},
'b': {'gid': 1500} "b": {"gid": 1500},
} }
build = users.build_users(defs, 'ex.com', 1500, 'pw') build = users.build_users(defs, "ex.com", 1500, "pw")
# Both should have gid 1500 # Both should have gid 1500
self.assertEqual(build['a']['gid'], 1500) self.assertEqual(build["a"]["gid"], 1500)
self.assertEqual(build['b']['gid'], 1500) self.assertEqual(build["b"]["gid"], 1500)
def test_build_users_duplicate_username_email(self): def test_build_users_duplicate_username_email(self):
defs = { defs = {
'u1': {'username': 'same', 'email': 'same@ex.com'}, "u1": {"username": "same", "email": "same@ex.com"},
'u2': {'username': 'same'} "u2": {"username": "same"},
} }
# second user with same username should raise # second user with same username should raise
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
users.build_users(defs, 'ex.com', 1001, 'pw') users.build_users(defs, "ex.com", 1001, "pw")
def test_dictify_converts_ordereddict(self): def test_dictify_converts_ordereddict(self):
od = users.OrderedDict([('a', 1), ('b', {'c': 2})]) od = users.OrderedDict([("a", 1), ("b", {"c": 2})])
result = users.dictify(OrderedDict(od)) result = users.dictify(OrderedDict(od))
self.assertIsInstance(result, dict) self.assertIsInstance(result, dict)
self.assertEqual(result, {'a': 1, 'b': {'c': 2}}) self.assertEqual(result, {"a": 1, "b": {"c": 2}})
def test_load_user_defs_and_conflict(self): def test_load_user_defs_and_conflict(self):
# create temp roles structure # create temp roles structure
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
try: try:
os.makedirs(os.path.join(tmp, 'role1/users')) os.makedirs(os.path.join(tmp, "role1/users"))
os.makedirs(os.path.join(tmp, 'role2/users')) os.makedirs(os.path.join(tmp, "role2/users"))
# role1 defines user x # role1 defines user x
with open(os.path.join(tmp, 'role1/users/main.yml'), 'w') as f: with open(os.path.join(tmp, "role1/users/main.yml"), "w") as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) yaml.safe_dump({"users": {"x": {"email": "x@a"}}}, f)
# role2 defines same user x with same value # role2 defines same user x with same value
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f: with open(os.path.join(tmp, "role2/users/main.yml"), "w") as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) yaml.safe_dump({"users": {"x": {"email": "x@a"}}}, f)
defs = users.load_user_defs(tmp) defs = users.load_user_defs(tmp)
self.assertIn('x', defs) self.assertIn("x", defs)
# now conflict definition # now conflict definition
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f: with open(os.path.join(tmp, "role2/users/main.yml"), "w") as f:
yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f) yaml.safe_dump({"users": {"x": {"email": "x@b"}}}, f)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
users.load_user_defs(tmp) users.load_user_defs(tmp)
finally: finally:
@@ -136,7 +132,6 @@ class TestGenerateUsers(unittest.TestCase):
""" """
Ensure that default_users keys are written in alphabetical order. Ensure that default_users keys are written in alphabetical order.
""" """
import tempfile
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -147,10 +142,10 @@ class TestGenerateUsers(unittest.TestCase):
# Create multiple roles with users in unsorted order # Create multiple roles with users in unsorted order
for role, users_map in [ for role, users_map in [
("role-zeta", {"zeta": {"email": "z@ex"}}), ("role-zeta", {"zeta": {"email": "z@ex"}}),
("role-alpha", {"alpha": {"email": "a@ex"}}), ("role-alpha", {"alpha": {"email": "a@ex"}}),
("role-mu", {"mu": {"email": "m@ex"}}), ("role-mu", {"mu": {"email": "m@ex"}}),
("role-beta", {"beta": {"email": "b@ex"}}), ("role-beta", {"beta": {"email": "b@ex"}}),
]: ]:
(roles_dir / role / "users").mkdir(parents=True, exist_ok=True) (roles_dir / role / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / role / "users" / "main.yml", "w") as f: with open(roles_dir / role / "users" / "main.yml", "w") as f:
@@ -158,15 +153,20 @@ class TestGenerateUsers(unittest.TestCase):
out_file = tmpdir / "users.yml" out_file = tmpdir / "users.yml"
# Resolve script path like in other tests (relative to repo root) # Always resolve the real script path from the imported module
script_path = Path(__file__).resolve().parents[4] / "cli" / "setup" / "users.py" script_path = Path(users.__file__).resolve()
# Run generator
result = subprocess.run( result = subprocess.run(
["python3", str(script_path), [
"--roles-dir", str(roles_dir), "python3",
"--output", str(out_file)], str(script_path),
capture_output=True, text=True "--roles-dir",
str(roles_dir),
"--output",
str(out_file),
],
capture_output=True,
text=True,
) )
self.assertEqual(result.returncode, 0, msg=result.stderr) self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertTrue(out_file.exists(), "Output file was not created.") self.assertTrue(out_file.exists(), "Output file was not created.")
@@ -176,24 +176,21 @@ class TestGenerateUsers(unittest.TestCase):
users_map = data["default_users"] users_map = data["default_users"]
keys_in_file = list(users_map.keys()) keys_in_file = list(users_map.keys())
# Expect alphabetical order
self.assertEqual( self.assertEqual(
keys_in_file, sorted(keys_in_file), keys_in_file,
msg=f"Users are not sorted alphabetically: {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"]: for k in ["alpha", "beta", "mu", "zeta"]:
self.assertIn(k, users_map) self.assertIn(k, users_map)
finally: finally:
shutil.rmtree(tmpdir) shutil.rmtree(tmpdir)
def test_cli_users_sorting_stable_across_runs(self): def test_cli_users_sorting_stable_across_runs(self):
""" """
Running the generator multiple times yields identical content (stable sort). Running the generator multiple times yields identical content (stable sort).
""" """
import tempfile
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -202,7 +199,6 @@ class TestGenerateUsers(unittest.TestCase):
roles_dir = tmpdir / "roles" roles_dir = tmpdir / "roles"
roles_dir.mkdir() roles_dir.mkdir()
# Unsorted creation order on purpose
cases = [ cases = [
("role-d", {"duser": {"email": "d@ex"}}), ("role-d", {"duser": {"email": "d@ex"}}),
("role-a", {"auser": {"email": "a@ex"}}), ("role-a", {"auser": {"email": "a@ex"}}),
@@ -215,35 +211,45 @@ class TestGenerateUsers(unittest.TestCase):
yaml.safe_dump({"users": users_map}, f) yaml.safe_dump({"users": users_map}, f)
out_file = tmpdir / "users.yml" out_file = tmpdir / "users.yml"
script_path = Path(__file__).resolve().parents[5] / "cli" / "setup" / "users.py" script_path = Path(users.__file__).resolve()
# First run
r1 = subprocess.run( r1 = subprocess.run(
["python3", str(script_path), [
"--roles-dir", str(roles_dir), "python3",
"--output", str(out_file)], str(script_path),
capture_output=True, text=True "--roles-dir",
str(roles_dir),
"--output",
str(out_file),
],
capture_output=True,
text=True,
) )
self.assertEqual(r1.returncode, 0, msg=r1.stderr) self.assertEqual(r1.returncode, 0, msg=r1.stderr)
content1 = out_file.read_text() content1 = out_file.read_text()
# Touch dirs to shuffle filesystem mtimes
for p in roles_dir.iterdir(): for p in roles_dir.iterdir():
os.utime(p, None) os.utime(p, None)
# Second run
r2 = subprocess.run( r2 = subprocess.run(
["python3", str(script_path), [
"--roles-dir", str(roles_dir), "python3",
"--output", str(out_file)], str(script_path),
capture_output=True, text=True "--roles-dir",
str(roles_dir),
"--output",
str(out_file),
],
capture_output=True,
text=True,
) )
self.assertEqual(r2.returncode, 0, msg=r2.stderr) self.assertEqual(r2.returncode, 0, msg=r2.stderr)
content2 = out_file.read_text() content2 = out_file.read_text()
self.assertEqual( self.assertEqual(
content1, content2, content1,
msg="Output differs between runs; user sorting should be stable." content2,
msg="Output differs between runs; user sorting should be stable.",
) )
finally: finally:
shutil.rmtree(tmpdir) shutil.rmtree(tmpdir)
@@ -265,11 +271,8 @@ class TestGenerateUsers(unittest.TestCase):
become_pwd="pw", become_pwd="pw",
) )
# Reserved user should carry the flag
self.assertIn("reserved", build["admin"]) self.assertIn("reserved", build["admin"])
self.assertTrue(build["admin"]["reserved"]) self.assertTrue(build["admin"]["reserved"])
# Non-reserved user should not have the flag at all
self.assertNotIn("reserved", build["bob"]) self.assertNotIn("reserved", build["bob"])
def test_cli_reserved_usernames_flag_sets_reserved_field(self): def test_cli_reserved_usernames_flag_sets_reserved_field(self):
@@ -278,7 +281,6 @@ class TestGenerateUsers(unittest.TestCase):
in the generated YAML, and that existing definitions are preserved in the generated YAML, and that existing definitions are preserved
(only 'reserved' is added). (only 'reserved' is added).
""" """
import tempfile
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -287,7 +289,6 @@ class TestGenerateUsers(unittest.TestCase):
roles_dir = tmpdir / "roles" roles_dir = tmpdir / "roles"
roles_dir.mkdir() roles_dir.mkdir()
# Role with an existing user definition "admin"
(roles_dir / "role-base" / "users").mkdir(parents=True, exist_ok=True) (roles_dir / "role-base" / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / "role-base" / "users" / "main.yml", "w") as f: with open(roles_dir / "role-base" / "users" / "main.yml", "w") as f:
yaml.safe_dump( yaml.safe_dump(
@@ -303,7 +304,7 @@ class TestGenerateUsers(unittest.TestCase):
) )
out_file = tmpdir / "users.yml" out_file = tmpdir / "users.yml"
script_path = Path(__file__).resolve().parents[5] / "cli" / "setup" / "users.py" script_path = Path(users.__file__).resolve()
result = subprocess.run( result = subprocess.run(
[ [
@@ -326,12 +327,9 @@ class TestGenerateUsers(unittest.TestCase):
self.assertIn("default_users", data) self.assertIn("default_users", data)
users_map = data["default_users"] users_map = data["default_users"]
# "service" was created from the reserved list and must be reserved
self.assertIn("service", users_map) self.assertIn("service", users_map)
self.assertTrue(users_map["service"].get("reserved", False)) self.assertTrue(users_map["service"].get("reserved", False))
# "admin" existed before; its fields must remain unchanged,
# but it must now be marked as reserved
self.assertIn("admin", users_map) self.assertIn("admin", users_map)
self.assertEqual(users_map["admin"]["email"], "admin@ex") self.assertEqual(users_map["admin"]["email"], "admin@ex")
self.assertEqual(users_map["admin"]["description"], "Admin from role") self.assertEqual(users_map["admin"]["description"], "Admin from role")
@@ -340,5 +338,6 @@ class TestGenerateUsers(unittest.TestCase):
finally: finally:
shutil.rmtree(tmpdir) shutil.rmtree(tmpdir)
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -74,7 +74,7 @@ class TestMainHelpers(unittest.TestCase):
main.show_full_help_for_all("/fake/cli", available) main.show_full_help_for_all("/fake/cli", available)
expected_modules = {"cli.deploy", "cli.build.defaults.users"} expected_modules = {"cli.deploy", "cli.setup.users"}
invoked_modules = set() invoked_modules = set()
for call in mock_run.call_args_list: for call in mock_run.call_args_list: