diff --git a/Makefile b/Makefile index 3cd3b915..23792179 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,12 @@ USERS_SCRIPT := ./cli/generate_users.py INCLUDES_OUT := ./tasks/utils/docker-roles.yml INCLUDES_SCRIPT := ./cli/generate_playbook.py +EXTRA_USERS := $(shell \ + find $(ROLES_DIR) -maxdepth 1 -type d -name 'docker*' -printf '%f\n' \ + | sed -E 's/^docker[_-]?//' \ + | paste -sd, - \ +) + .PHONY: build install test build: @@ -15,7 +21,7 @@ build: @echo "✅ Applications defaults written to $(APPLICATIONS_OUT)\n" @echo "🔧 Generating users defaults → $(USERS_OUT) from roles in $(ROLES_DIR)…" @mkdir -p $(dir $(USERS_OUT)) - python3 $(USERS_SCRIPT) --roles-dir $(ROLES_DIR) --output $(USERS_OUT) + python3 $(USERS_SCRIPT) --roles-dir $(ROLES_DIR) --output $(USERS_OUT) --extra-users "$(EXTRA_USERS)" @echo "✅ Users defaults written to $(USERS_OUT)\n" @echo "🔧 Generating Docker role includes → $(INCLUDES_OUT)…" @mkdir -p $(dir $(INCLUDES_OUT)) diff --git a/cli/generate_users.py b/cli/generate_users.py index 3360f7a0..37344532 100644 --- a/cli/generate_users.py +++ b/cli/generate_users.py @@ -25,8 +25,7 @@ def build_users(defs, primary_domain, start_id, become_pwd): """ users = OrderedDict() used_uids = set() - used_gids = set() - + # Pre-collect any provided uids/gids and check for duplicates for key, overrides in defs.items(): if 'uid' in overrides: @@ -34,11 +33,6 @@ def build_users(defs, primary_domain, start_id, become_pwd): if uid in used_uids: raise ValueError(f"Duplicate uid {uid} for user '{key}'") used_uids.add(uid) - if 'gid' in overrides: - gid = overrides['gid'] - if gid in used_gids: - raise ValueError(f"Duplicate gid {gid} for user '{key}'") - used_gids.add(gid) next_free = start_id def allocate_free_id(): @@ -48,7 +42,6 @@ def build_users(defs, primary_domain, start_id, become_pwd): next_free += 1 free = next_free used_uids.add(free) - used_gids.add(free) next_free += 1 return free @@ -68,13 +61,8 @@ def build_users(defs, primary_domain, start_id, become_pwd): if 'gid' in overrides: gid = overrides['gid'] else: - # if gid not provided, default to uid (and ensure uniqueness) - if uid in used_gids: - # already added in allocate_free_id or pre-collect - gid = uid - else: - gid = uid - used_gids.add(gid) + # default GID to UID + gid = uid entry = { 'username': username, @@ -102,8 +90,6 @@ def build_users(defs, primary_domain, start_id, become_pwd): raise ValueError(f"Duplicate username '{un}' in merged users") if em in seen_emails: raise ValueError(f"Duplicate email '{em}' in merged users") - if gd in seen_gids: - raise ValueError(f"Duplicate gid '{gd}' in merged users") seen_usernames.add(un) seen_emails.add(em) seen_gids.add(gd) @@ -185,6 +171,11 @@ def parse_args(): '--start-id', '-s', type=int, default=1001, help='Starting uid/gid number (default: 1001).' ) + parser.add_argument( + '--extra-users', '-e', + help='Comma-separated list of additional usernames to include.', + default=None + ) return parser.parse_args() @@ -199,6 +190,17 @@ def main(): print(f"Error merging user definitions: {e}", file=sys.stderr) sys.exit(1) + # Add extra users if any + if args.extra_users: + for name in args.extra_users.split(','): + user = name.strip() + if not user: + continue + if user in user_defs: + print(f"Warning: extra user '{user}' already defined; skipping.", file=sys.stderr) + else: + user_defs[user] = {} + try: users = build_users( defs=user_defs, diff --git a/roles/docker-espocrm/vars/configuration.yml b/roles/docker-espocrm/vars/configuration.yml index fa6f24f6..c4d68af3 100644 --- a/roles/docker-espocrm/vars/configuration.yml +++ b/roles/docker-espocrm/vars/configuration.yml @@ -3,7 +3,7 @@ images: users: administrator: username: "administrator" - crm: + contact: description: "General contact account" username: "contact" features: diff --git a/roles/user/vars/configuration.yml b/roles/user/vars/configuration.yml new file mode 100644 index 00000000..c44464a1 --- /dev/null +++ b/roles/user/vars/configuration.yml @@ -0,0 +1,126 @@ +# Reserved usernames +users: + root: + username: root + uid: 0 + gid: 0 + description: "System superuser" + daemon: + username: daemon + description: "Daemon processes owner" + bin: + username: bin + description: "Owner of essential binaries" + sys: + username: sys + description: "System files owner" + sync: + username: sync + description: "Sync user for filesystem synchronization" + games: + username: games + description: "Games and educational software owner" + man: + username: man + description: "Manual pages viewer" + lp: + username: lp + description: "Printer spooler" + mail: + username: mail + description: "Mail system" + news: + username: news + description: "Network news system" + uucp: + username: uucp + description: "UUCP system" + proxy: + username: proxy + description: "Proxy user" + www-data: + username: www-data + description: "Web server user" + backup: + username: backup + description: "Backup operator" + list: + username: list + description: "Mailing list manager" + irc: + username: irc + description: "IRC services user" + gnats: + username: gnats + description: "GNATS bug-reporting system" + nobody: + username: nobody + description: "Unprivileged user" + messagebus: + username: messagebus + description: "D-Bus message bus system" + sshd: + username: sshd + description: "SSH daemon" + rpc: + username: rpc + description: "Rpcbind daemon" + ftp: + username: ftp + description: "FTP server" + postfix: + username: postfix + description: "Postfix mail transfer agent" + mysql: + username: mysql + description: "MySQL database server" + mongodb: + username: mongodb + description: "MongoDB database server" + admin: + username: admin + description: "Generic reserved username" + administrator: + username: administrator + user: + username: user + description: "Generic reserved username" + test: + username: test + description: "Generic reserved username" + guest: + username: guest + description: "Generic reserved username" + demo: + username: demo + description: "Generic reserved username" + info: + username: info + description: "Generic reserved username" + support: + username: support + description: "Generic reserved username" + helpdesk: + username: helpdesk + description: "Generic reserved username" + operator: + username: operator + description: "Generic reserved username" + staff: + username: staff + description: "Generic reserved username" + smtp: + username: smtp + description: "Generic reserved username" + imap: + username: imap + description: "Generic reserved username" + pop: + username: pop + description: "Generic reserved username" + webmaster: + username: webmaster + description: "Generic reserved username" + mailman: + username: mailman + description: "Generic reserved username" diff --git a/tests/unit/test_generate_users.py b/tests/unit/test_generate_users.py new file mode 100644 index 00000000..760f7b06 --- /dev/null +++ b/tests/unit/test_generate_users.py @@ -0,0 +1,97 @@ +import os +import sys +import unittest +import tempfile +import shutil +import yaml +from collections import OrderedDict + +# Add cli/ to import path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "cli"))) + +import generate_users + +class TestGenerateUsers(unittest.TestCase): + def test_build_users_auto_increment_and_overrides(self): + defs = { + 'alice': {}, + 'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'}, + 'carol': {} + } + users = generate_users.build_users( + defs=defs, + primary_domain='example.com', + start_id=1001, + become_pwd='pw' + ) + # alice should get uid/gid 1001 + self.assertEqual(users['alice']['uid'], 1001) + self.assertEqual(users['alice']['gid'], 1001) + self.assertEqual(users['alice']['email'], 'alice@example.com') + # bob overrides + self.assertEqual(users['bob']['uid'], 2000) + self.assertEqual(users['bob']['gid'], 2000) + self.assertEqual(users['bob']['email'], 'bob@custom.com') + self.assertIn('description', users['bob']) + # carol should get next free id = 1002 + self.assertEqual(users['carol']['uid'], 1002) + self.assertEqual(users['carol']['gid'], 1002) + + def test_build_users_duplicate_override_uid(self): + defs = { + 'u1': {'uid': 1001}, + 'u2': {'uid': 1001} + } + with self.assertRaises(ValueError): + generate_users.build_users(defs, 'ex.com', 1001, 'pw') + + 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 + defs = { + 'a': {'uid': 1500}, + 'b': {'gid': 1500} + } + users = generate_users.build_users(defs, 'ex.com', 1500, 'pw') + # Both should have gid 1500 + self.assertEqual(users['a']['gid'], 1500) + self.assertEqual(users['b']['gid'], 1500) + + def test_build_users_duplicate_username_email(self): + defs = { + 'u1': {'username': 'same', 'email': 'same@ex.com'}, + 'u2': {'username': 'same'} + } + # second user with same username should raise + with self.assertRaises(ValueError): + generate_users.build_users(defs, 'ex.com', 1001, 'pw') + + def test_dictify_converts_ordereddict(self): + od = generate_users.OrderedDict([('a', 1), ('b', {'c': 2})]) + result = generate_users.dictify(OrderedDict(od)) + self.assertIsInstance(result, dict) + self.assertEqual(result, {'a': 1, 'b': {'c': 2}}) + + def test_load_user_defs_and_conflict(self): + # create temp roles structure + tmp = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(tmp, 'role1/vars')) + os.makedirs(os.path.join(tmp, 'role2/vars')) + # role1 defines user x + with open(os.path.join(tmp, 'role1/vars/configuration.yml'), 'w') as f: + yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) + # role2 defines same user x with same value + with open(os.path.join(tmp, 'role2/vars/configuration.yml'), 'w') as f: + yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) + defs = generate_users.load_user_defs(tmp) + self.assertIn('x', defs) + # now conflict definition + with open(os.path.join(tmp, 'role2/vars/configuration.yml'), 'w') as f: + yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f) + with self.assertRaises(ValueError): + generate_users.load_user_defs(tmp) + finally: + shutil.rmtree(tmp) + +if __name__ == '__main__': + unittest.main()