Updated user logic

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-02 18:29:53 +02:00
parent 9cf18cae0e
commit 03db141316
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
42 changed files with 166 additions and 79 deletions

View File

@ -1,7 +1,7 @@
ROLES_DIR := ./roles ROLES_DIR := ./roles
APPLICATIONS_OUT := ./group_vars/all/03_applications.yml APPLICATIONS_OUT := ./group_vars/all/04_applications.yml
APPLICATIONS_SCRIPT := ./cli/generate-applications-defaults.py APPLICATIONS_SCRIPT := ./cli/generate-applications-defaults.py
USERS_OUT := ./group_vars/all/10_users.yml USERS_OUT := ./group_vars/all/03_users.yml
USERS_SCRIPT := ./cli/generate_users.py USERS_SCRIPT := ./cli/generate_users.py
INCLUDES_OUT := ./tasks/utils/docker-roles.yml INCLUDES_OUT := ./tasks/utils/docker-roles.yml
INCLUDES_SCRIPT := ./cli/generate_playbook.py INCLUDES_SCRIPT := ./cli/generate_playbook.py
@ -16,13 +16,11 @@ EXTRA_USERS := $(shell \
build: build:
@echo "🔧 Generating applications defaults → $(APPLICATIONS_OUT) from roles in $(ROLES_DIR)" @echo "🔧 Generating applications defaults → $(APPLICATIONS_OUT) from roles in $(ROLES_DIR)"
@mkdir -p $(dir $(APPLICATIONS_OUT)) python3 $(USERS_SCRIPT) --roles-dir $(ROLES_DIR) --output $(USERS_OUT) --extra-users "$(EXTRA_USERS)"
@echo "✅ Users defaults written to $(USERS_OUT)\n"
python3 $(APPLICATIONS_SCRIPT) --roles-dir $(ROLES_DIR) --output-file $(APPLICATIONS_OUT) python3 $(APPLICATIONS_SCRIPT) --roles-dir $(ROLES_DIR) --output-file $(APPLICATIONS_OUT)
@echo "✅ Applications defaults written to $(APPLICATIONS_OUT)\n" @echo "✅ Applications defaults written to $(APPLICATIONS_OUT)\n"
@echo "🔧 Generating users defaults → $(USERS_OUT) from roles in $(ROLES_DIR)" @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) --extra-users "$(EXTRA_USERS)"
@echo "✅ Users defaults written to $(USERS_OUT)\n"
@echo "🔧 Generating Docker role includes → $(INCLUDES_OUT)" @echo "🔧 Generating Docker role includes → $(INCLUDES_OUT)"
@mkdir -p $(dir $(INCLUDES_OUT)) @mkdir -p $(dir $(INCLUDES_OUT))
python3 $(INCLUDES_SCRIPT) $(ROLES_DIR) -o $(INCLUDES_OUT) -p docker- python3 $(INCLUDES_SCRIPT) $(ROLES_DIR) -o $(INCLUDES_OUT) -p docker-

View File

@ -13,20 +13,31 @@ def load_yaml_file(path):
with path.open("r", encoding="utf-8") as f: with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f) or {} return yaml.safe_load(f) or {}
def main(): def main():
parser = argparse.ArgumentParser(description="Generate defaults_applications YAML from docker roles.") parser = argparse.ArgumentParser(
parser.add_argument("--roles-dir", default="roles", help="Path to the roles directory (default: roles)") description="Generate defaults_applications YAML from docker roles and include users meta data for each role."
parser.add_argument("--output-file", default="group_vars/all/03_applications.yml", help="Path to output YAML file") )
parser.add_argument(
"--roles-dir",
help="Path to the roles directory (default: roles)"
)
parser.add_argument(
"--output-file",
help="Path to output YAML file"
)
args = parser.parse_args() args = parser.parse_args()
cwd = Path.cwd() cwd = Path.cwd()
roles_dir = (cwd / args.roles_dir).resolve() roles_dir = (cwd / args.roles_dir).resolve()
output_file = (cwd / args.output_file).resolve() output_file = (cwd / args.output_file).resolve()
# Ensure output directory exists
output_file.parent.mkdir(parents=True, exist_ok=True) output_file.parent.mkdir(parents=True, exist_ok=True)
# Initialize result structure
result = {"defaults_applications": {}} result = {"defaults_applications": {}}
# Process each role for application configs
for role_dir in sorted(roles_dir.iterdir()): for role_dir in sorted(roles_dir.iterdir()):
role_name = role_dir.name role_name = role_dir.name
vars_main = role_dir / "vars" / "main.yml" vars_main = role_dir / "vars" / "main.yml"
@ -40,9 +51,10 @@ def main():
try: try:
application_id = vars_data.get("application_id") application_id = vars_data.get("application_id")
except Exception as e: except Exception as e:
# print the exception message print(
print(f"Warning: failed to read application_id from {vars_data} in {vars_main}.\nException: {e}", file=sys.stderr) f"Warning: failed to read application_id from {vars_main}\nException: {e}",
# exit with status 0 file=sys.stderr
)
sys.exit(1) sys.exit(1)
if not application_id: if not application_id:
@ -56,7 +68,19 @@ def main():
config_data = load_yaml_file(config_file) config_data = load_yaml_file(config_file)
if config_data: if config_data:
result["defaults_applications"][application_id] = config_data result["defaults_applications"][application_id] = config_data
users_meta_file = role_dir / "meta" / "users.yml"
transformed_users = {}
if users_meta_file.exists():
users_meta = load_yaml_file(users_meta_file)
users_data = users_meta.get("users", {})
for user, role_user_attrs in users_data.items():
transformed_users[user] = f"{{{{ users[\"{user}\"] }}}}"
# Attach transformed users under each application
if transformed_users:
result["defaults_applications"][application_id]["users"] = transformed_users
# Write out result YAML
with output_file.open("w", encoding="utf-8") as f: with output_file.open("w", encoding="utf-8") as f:
yaml.dump(result, f, sort_keys=False) yaml.dump(result, f, sort_keys=False)
@ -65,5 +89,6 @@ def main():
except ValueError: except ValueError:
print(f"✅ Generated: {output_file}") print(f"✅ Generated: {output_file}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -93,7 +93,7 @@ def build_users(defs, primary_domain, start_id, become_pwd):
def load_user_defs(roles_dir): def load_user_defs(roles_dir):
""" """
Scan all roles/*/vars/configuration.yml files and extract 'users:' sections. Scan all roles/*/meta/users.yml files and extract 'users:' sections.
Raises an exception if conflicting definitions are found. Raises an exception if conflicting definitions are found.
@ -106,7 +106,7 @@ def load_user_defs(roles_dir):
Raises: Raises:
ValueError: On invalid format or conflicting field values. ValueError: On invalid format or conflicting field values.
""" """
pattern = os.path.join(roles_dir, '*/vars/configuration.yml') pattern = os.path.join(roles_dir, '*/meta/users.yml')
files = sorted(glob.glob(pattern)) files = sorted(glob.glob(pattern))
merged = OrderedDict() merged = OrderedDict()
@ -151,11 +151,11 @@ def dictify(data):
def parse_args(): def parse_args():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Generate a users.yml by merging all roles/*/vars/configuration.yml users sections.' description='Generate a users.yml by merging all roles/*/meta/users.yml users sections.'
) )
parser.add_argument( parser.add_argument(
'--roles-dir', '-r', required=True, '--roles-dir', '-r', required=True,
help='Directory containing roles (e.g., roles/*/vars/configuration.yml).' help='Directory containing roles (e.g., roles/*/meta/users.yml).'
) )
parser.add_argument( parser.add_argument(
'--output', '-o', required=True, '--output', '-o', required=True,

View File

@ -0,0 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}"

View File

@ -1,6 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}"
images: images:
pds: "ghcr.io/bluesky-social/pds:latest" pds: "ghcr.io/bluesky-social/pds:latest"
pds: pds:

View File

@ -0,0 +1,6 @@
users:
administrator:
username: "administrator"
contact:
description: "General contact account"
username: "contact"

View File

@ -1,11 +1,5 @@
images: images:
espocrm: "espocrm/espocrm:latest" espocrm: "espocrm/espocrm:latest"
users:
administrator:
username: "administrator"
contact:
description: "General contact account"
username: "contact"
features: features:
matomo: true matomo: true
css: false css: false

View File

@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@ -1,8 +1,5 @@
images: images:
keycloak: "quay.io/keycloak/keycloak:latest" keycloak: "quay.io/keycloak/keycloak:latest"
users:
administrator:
username: "administrator"
import_realm: True # If True realm will be imported. If false skip. import_realm: True # If True realm will be imported. If false skip.
credentials: credentials:
features: features:

View File

@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@ -6,9 +6,6 @@ network:
public: False # Set to true in inventory file if you want to expose the LDAP port to the internet public: False # Set to true in inventory file if you want to expose the LDAP port to the internet
hostname: "ldap" # Hostname of the LDAP Server in the central_ldap network hostname: "ldap" # Hostname of the LDAP Server in the central_ldap network
webinterface: "lam" # The webinterface which should be used. Possible: lam and phpldapadmin webinterface: "lam" # The webinterface which should be used. Possible: lam and phpldapadmin
users:
administrator:
username: "administrator"
credentials: credentials:
features: features:
ldap: true ldap: true

View File

@ -0,0 +1,7 @@
users:
administrator:
username: "administrator"
bounce:
username: "bounce"
newsletter:
username: "newsletter"

View File

@ -1,12 +1,5 @@
images: images:
listmonk: "listmonk/listmonk:latest" listmonk: "listmonk/listmonk:latest"
users:
administrator:
username: "administrator"
bounce:
username: "bounce"
newsletter:
username: "newsletter"
public_api_activated: False # Security hole. Can be used for spaming public_api_activated: False # Security hole. Can be used for spaming
version: "latest" # Docker Image version version: "latest" # Docker Image version
features: features:

View File

@ -0,0 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}" # Administrator Email for DNS Records

View File

@ -1,7 +1,4 @@
version: "2024.06" # Docker Image Version version: "2024.06" # Docker Image Version
users:
administrator:
email: "administrator@{{ primary_domain }}" # Administrator Email for DNS Records
oidc: oidc:
email_by_username: true # If true, then the mail is set by the username. If wrong then the OIDC user email is used email_by_username: true # If true, then the mail is set by the username. If wrong then the OIDC user email is used
enable_user_creation: true # Users will be created if not existing enable_user_creation: true # Users will be created if not existing

View File

@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@ -1,9 +1,6 @@
images: images:
synapse: "matrixdotorg/synapse:latest" synapse: "matrixdotorg/synapse:latest"
element: "vectorim/element-web:latest" element: "vectorim/element-web:latest"
users:
administrator:
username: "administrator"
playbook_tags: "setup-all,start" # For the initial update use: install-all,ensure-matrix-users-created,start playbook_tags: "setup-all,start" # For the initial update use: install-all,ensure-matrix-users-created,start
server_name: "{{primary_domain}}" # Adress for the account names etc. server_name: "{{primary_domain}}" # Adress for the account names etc.
synapse: synapse:

View File

@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@ -1,7 +1,4 @@
site_titel: "Academy on {{primary_domain}}" site_titel: "Academy on {{primary_domain}}"
users:
administrator:
username: "administrator"
version: "4.5" # Latest LTS - Necessary for OIDC version: "4.5" # Latest LTS - Necessary for OIDC
features: features:
matomo: true matomo: true

View File

@ -0,0 +1,5 @@
users:
administrator:
username: "administrator"
no-reply:
username: "no-reply"

View File

@ -27,11 +27,6 @@ features:
ldap: true ldap: true
oidc: true oidc: true
central_database: true central_database: true
users:
administrator:
username: "administrator"
no-reply:
username: "no-reply"
default_quota: '1000000000' # Quota to assign if no quota is specified in the OIDC response (bytes) default_quota: '1000000000' # Quota to assign if no quota is specified in the OIDC response (bytes)
legacy_login_mask: legacy_login_mask:
enabled: False # If true, then legacy login mask is shown. Otherwise just SSO enabled: False # If true, then legacy login mask is shown. Otherwise just SSO

View File

@ -0,0 +1,3 @@
users:
administrator:
email: "administrator@{{ primary_domain }}"

View File

@ -1,9 +1,6 @@
version: "latest" version: "latest"
server_mode: False # If true then the preconfigured database file is loaded. Recommended False. True is a security risk. server_mode: False # If true then the preconfigured database file is loaded. Recommended False. True is a security risk.
master_password_required: True # Master password is required. Recommended True. False is a security risk. master_password_required: True # Master password is required. Recommended True. False is a security risk.
users:
administrator:
email: "administrator@{{ primary_domain }}"
oauth2_proxy: oauth2_proxy:
application: "application" application: "application"
port: "80" port: "80"

View File

@ -0,0 +1,4 @@
users: # Credentials
administrator: # Wordpress administrator
username: "administrator"
email: "administrator@{{ primary_domain }}"

View File

@ -1,8 +1,4 @@
title: "Blog" # Wordpress titel title: "Blog" # Wordpress titel
users: # Credentials
administrator: # Wordpress administrator
username: "administrator"
email: "administrator@{{ primary_domain }}"
plugins: plugins:
wp-discourse: wp-discourse:
enabled: "{{ 'discourse' in group_names | lower }}" enabled: "{{ 'discourse' in group_names | lower }}"

View File

@ -0,0 +1,3 @@
users:
administrator:
username: "administrator"

View File

@ -1,6 +1,3 @@
users:
administrator:
username: "administrator"
version: "latest" version: "latest"
oauth2_proxy: oauth2_proxy:
application: "application" application: "application"

View File

@ -1,7 +0,0 @@
users:
sld:
description: "Auto Generated Account to reserve the SLD"
username: "{{ primary_domain.split('.')[0] }}"
tld:
description: "Auto Generated Account to reserve the TLD"
username: "{{ primary_domain.split('.')[1] }}"

View File

@ -1,5 +1,11 @@
# Reserved usernames # Reserved usernames
users: users:
sld:
description: "Auto Generated Account to reserve the SLD"
username: "{{ primary_domain.split('.')[0] }}"
tld:
description: "Auto Generated Account to reserve the TLD"
username: "{{ primary_domain.split('.')[1] }}"
root: root:
username: root username: root
uid: 0 uid: 0

View File

@ -12,7 +12,7 @@ class TestDomainUniqueness(unittest.TestCase):
and assert that no domain appears more than once. and assert that no domain appears more than once.
""" """
repo_root = Path(__file__).resolve().parents[2] repo_root = Path(__file__).resolve().parents[2]
yaml_file = repo_root / 'group_vars' / 'all' / '03_applications.yml' yaml_file = repo_root / 'group_vars' / 'all' / '04_applications.yml'
# Generate the file if it doesn't exist # Generate the file if it doesn't exist
if not yaml_file.exists(): if not yaml_file.exists():

View File

@ -8,7 +8,7 @@ class TestOAuth2ProxyPorts(unittest.TestCase):
def setUpClass(cls): def setUpClass(cls):
# Set up root paths and load oauth2_proxy ports mapping # Set up root paths and load oauth2_proxy ports mapping
cls.ROOT = Path(__file__).parent.parent.parent.resolve() cls.ROOT = Path(__file__).parent.parent.parent.resolve()
cls.PORTS_FILE = cls.ROOT / 'group_vars' / 'all' / '08_ports.yml' cls.PORTS_FILE = cls.ROOT / 'group_vars' / 'all' / '09_ports.yml'
with cls.PORTS_FILE.open() as f: with cls.PORTS_FILE.open() as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
cls.oauth2_ports = ( cls.oauth2_ports = (
@ -50,7 +50,7 @@ class TestOAuth2ProxyPorts(unittest.TestCase):
if app_id not in self.oauth2_ports: if app_id not in self.oauth2_ports:
self.fail( self.fail(
f"Missing oauth2_proxy port mapping for application '{app_id}' " f"Missing oauth2_proxy port mapping for application '{app_id}' "
f"in group_vars/all/08_ports.yml" f"in group_vars/all/09_ports.yml"
) )

View File

@ -0,0 +1,65 @@
import os
import unittest
import tempfile
import shutil
import yaml
from pathlib import Path
import subprocess
class TestGenerateDefaultApplicationsUsers(unittest.TestCase):
def setUp(self):
# Setup temporary roles directory
self.temp_dir = Path(tempfile.mkdtemp())
self.roles_dir = self.temp_dir / "roles"
self.roles_dir.mkdir()
# Sample role with users meta
self.role = self.roles_dir / "docker-app-with-users"
(self.role / "vars").mkdir(parents=True)
(self.role / "meta").mkdir(parents=True)
# Write application_id and configuration
(self.role / "vars" / "main.yml").write_text("application_id: app_with_users\n")
(self.role / "vars" / "configuration.yml").write_text("setting: value\n")
# Write users meta
users_meta = {
'users': {
'alice': {'uid': 2001, 'gid': 2001},
'bob': {'uid': 2002, 'gid': 2002}
}
}
with (self.role / "meta" / "users.yml").open('w', encoding='utf-8') as f:
yaml.dump(users_meta, f)
# Output file path
self.output_file = self.temp_dir / "output.yml"
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_users_injection(self):
"""
When a users.yml exists with defined users, the script should inject a 'users'
mapping in the generated YAML, mapping each username to a Jinja2 reference.
"""
script_path = Path(__file__).resolve().parents[2] / "cli" / "generate-applications-defaults.py"
result = subprocess.run([
"python3", str(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)
data = yaml.safe_load(self.output_file.read_text())
apps = data.get('defaults_applications', {})
# Only the app with users should be present
self.assertIn('app_with_users', apps)
# 'users' section should be present and correct
users_map = apps['app_with_users']['users']
expected = {'alice': '{{ users["alice"] }}', 'bob': '{{ users["bob"] }}'}
self.assertEqual(users_map, expected)
if __name__ == '__main__':
unittest.main()

View File

@ -23,7 +23,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
(self.sample_role / "vars" / "configuration.yml").write_text("foo: bar\nbaz: 123\n") (self.sample_role / "vars" / "configuration.yml").write_text("foo: bar\nbaz: 123\n")
# Output file path # Output file path
self.output_file = self.temp_dir / "group_vars" / "all" / "03_applications.yml" self.output_file = self.temp_dir / "group_vars" / "all" / "04_applications.yml"
def tearDown(self): def tearDown(self):
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)

View File

@ -114,18 +114,18 @@ class TestGenerateUsers(unittest.TestCase):
# create temp roles structure # create temp roles structure
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
try: try:
os.makedirs(os.path.join(tmp, 'role1/vars')) os.makedirs(os.path.join(tmp, 'role1/meta'))
os.makedirs(os.path.join(tmp, 'role2/vars')) os.makedirs(os.path.join(tmp, 'role2/meta'))
# role1 defines user x # role1 defines user x
with open(os.path.join(tmp, 'role1/vars/configuration.yml'), 'w') as f: with open(os.path.join(tmp, 'role1/meta/users.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/vars/configuration.yml'), 'w') as f: with open(os.path.join(tmp, 'role2/meta/users.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f)
defs = generate_users.load_user_defs(tmp) defs = generate_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/vars/configuration.yml'), 'w') as f: with open(os.path.join(tmp, 'role2/meta/users.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):
generate_users.load_user_defs(tmp) generate_users.load_user_defs(tmp)