Compare commits

...

7 Commits

57 changed files with 769 additions and 216 deletions

View File

@@ -1,16 +1,26 @@
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
USERS_OUT := ./group_vars/all/03_users.yml
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:
@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)
@echo "✅ Applications defaults written to $(APPLICATIONS_OUT)\n"
@echo "🔧 Generating users defaults → $(USERS_OUT) from roles in $(ROLES_DIR)"
@echo "🔧 Generating Docker role includes → $(INCLUDES_OUT)"
@mkdir -p $(dir $(INCLUDES_OUT))
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:
return yaml.safe_load(f) or {}
def main():
parser = argparse.ArgumentParser(description="Generate defaults_applications YAML from docker roles.")
parser.add_argument("--roles-dir", default="roles", help="Path to the roles directory (default: roles)")
parser.add_argument("--output-file", default="group_vars/all/03_applications.yml", help="Path to output YAML file")
parser = argparse.ArgumentParser(
description="Generate defaults_applications YAML from docker roles and include users meta data for each role."
)
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()
cwd = Path.cwd()
roles_dir = (cwd / args.roles_dir).resolve()
output_file = (cwd / args.output_file).resolve()
# Ensure output directory exists
output_file.parent.mkdir(parents=True, exist_ok=True)
# Initialize result structure
result = {"defaults_applications": {}}
# Process each role for application configs
for role_dir in sorted(roles_dir.iterdir()):
role_name = role_dir.name
vars_main = role_dir / "vars" / "main.yml"
@@ -40,9 +51,10 @@ def main():
try:
application_id = vars_data.get("application_id")
except Exception as e:
# print the exception message
print(f"Warning: failed to read application_id from {vars_data} in {vars_main}.\nException: {e}", file=sys.stderr)
# exit with status 0
print(
f"Warning: failed to read application_id from {vars_main}\nException: {e}",
file=sys.stderr
)
sys.exit(1)
if not application_id:
@@ -56,7 +68,19 @@ def main():
config_data = load_yaml_file(config_file)
if 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:
yaml.dump(result, f, sort_keys=False)
@@ -65,5 +89,6 @@ def main():
except ValueError:
print(f"✅ Generated: {output_file}")
if __name__ == "__main__":
main()

228
cli/generate_users.py Normal file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
import os
import sys
import argparse
import yaml
import glob
from collections import OrderedDict
def build_users(defs, primary_domain, start_id, become_pwd):
"""
Build user entries with auto-incremented uid/gid, default username/email, and optional description.
Args:
defs (OrderedDict): Keys are user IDs, values are dicts with optional overrides.
primary_domain (str): e.g., 'example.com'.
start_id (int): Starting uid/gid (e.g., 1001).
become_pwd (str): Password string for all users.
Returns:
OrderedDict: Merged user definitions with full fields.
Raises:
ValueError: If duplicate override uids/gids or conflicts in generated values.
"""
users = OrderedDict()
used_uids = set()
# Pre-collect any provided uids/gids and check for duplicates
for key, overrides in defs.items():
if 'uid' in overrides:
uid = overrides['uid']
if uid in used_uids:
raise ValueError(f"Duplicate uid {uid} for user '{key}'")
used_uids.add(uid)
next_free = start_id
def allocate_free_id():
nonlocal next_free
# find next free id not in used_uids
while next_free in used_uids:
next_free += 1
free = next_free
used_uids.add(free)
next_free += 1
return free
# Build entries
for key, overrides in defs.items():
username = overrides.get('username', key)
email = overrides.get('email', f"{username}@{primary_domain}")
description = overrides.get('description')
password = overrides.get('password',become_pwd)
# UID assignment
if 'uid' in overrides:
uid = overrides['uid']
else:
uid = allocate_free_id()
gid = overrides.get('gid',uid)
entry = {
'username': username,
'email': email,
'password': password,
'uid': uid,
'gid': gid
}
if description is not None:
entry['description'] = description
if overrides.get('is_admin', False):
entry['is_admin'] = True
users[key] = entry
# Validate uniqueness of username, email, and gid
seen_usernames = set()
seen_emails = set()
seen_gids = set()
for key, entry in users.items():
un = entry['username']
em = entry['email']
gd = entry['gid']
if un in seen_usernames:
raise ValueError(f"Duplicate username '{un}' in merged users")
if em in seen_emails:
raise ValueError(f"Duplicate email '{em}' in merged users")
seen_usernames.add(un)
seen_emails.add(em)
seen_gids.add(gd)
return users
def load_user_defs(roles_dir):
"""
Scan all roles/*/meta/users.yml files and extract 'users:' sections.
Raises an exception if conflicting definitions are found.
Args:
roles_dir (str): Path to the directory containing role subdirectories.
Returns:
OrderedDict: Merged user definitions.
Raises:
ValueError: On invalid format or conflicting field values.
"""
pattern = os.path.join(roles_dir, '*/meta/users.yml')
files = sorted(glob.glob(pattern))
merged = OrderedDict()
for filepath in files:
with open(filepath, 'r') as f:
data = yaml.safe_load(f) or {}
users = data.get('users', {})
if not isinstance(users, dict):
continue
for key, overrides in users.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid definition for user '{key}' in {filepath}")
if key not in merged:
merged[key] = overrides.copy()
else:
existing = merged[key]
for field, value in overrides.items():
if field in existing and existing[field] != value:
raise ValueError(
f"Conflict for user '{key}': field '{field}' has existing value "
f"'{existing[field]}', tried to set '{value}' in {filepath}"
)
existing.update(overrides)
return merged
def dictify(data):
"""
Recursively convert OrderedDict to regular dict before YAML dump.
"""
if isinstance(data, OrderedDict):
return {k: dictify(v) for k, v in data.items()}
if isinstance(data, dict):
return {k: dictify(v) for k, v in data.items()}
if isinstance(data, list):
return [dictify(v) for v in data]
return data
def parse_args():
parser = argparse.ArgumentParser(
description='Generate a users.yml by merging all roles/*/meta/users.yml users sections.'
)
parser.add_argument(
'--roles-dir', '-r', required=True,
help='Directory containing roles (e.g., roles/*/meta/users.yml).'
)
parser.add_argument(
'--output', '-o', required=True,
help='Path to the output YAML file (e.g., users.yml).'
)
parser.add_argument(
'--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()
def main():
args = parse_args()
primary_domain = '{{ primary_domain }}'
become_pwd = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
try:
user_defs = load_user_defs(args.roles_dir)
except ValueError as e:
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,
primary_domain=primary_domain,
start_id=args.start_id,
become_pwd=become_pwd
)
except ValueError as e:
print(f"Error building user entries: {e}", file=sys.stderr)
sys.exit(1)
default_users = {'default_users': users}
plain_data = dictify(default_users)
# Ensure strings are represented without Python-specific tags
yaml.SafeDumper.add_representer(
str,
lambda dumper, data: dumper.represent_scalar('tag:yaml.org,2002:str', data)
)
with open(args.output, 'w') as f:
yaml.safe_dump(
plain_data,
f,
default_flow_style=False,
sort_keys=False,
width=120
)
if __name__ == '__main__':
main()

View File

@@ -1 +1,2 @@
*_applications.yml
*_users.yml

View File

@@ -3,7 +3,7 @@ default_system_email:
domain: "{{primary_domain}}"
host: "mail.{{primary_domain}}"
port: 465
tls: true
tls: true # true for TLS and false for SSL
start_tls: false
smtp: true
# password: # Needs to be defined in inventory file

View File

@@ -1,110 +0,0 @@
# Helper Variables
# Helper Variables for administrator
_users_administrator_username: "{{ users.administrator.username | default('administrator') }}"
_users_administrator_email: "{{ users.administrator.email | default(_users_administrator_username ~ '@' ~ primary_domain) }}"
# Helper Variables for bounce
_users_bounce_username: "{{ users.bounce.username | default('bounce') }}"
_users_bounce_email: "{{ users.bounce.email | default(_users_bounce_username ~ '@' ~ primary_domain) }}"
# Helper Variables for no-reply
_users_no_reply_username: "{{ users['no-reply'].username | default('no-reply') }}"
_users_no_reply_email: "{{ users['no-reply'].email | default(_users_no_reply_username ~ '@' ~ primary_domain) }}"
# Helper Variables for blackhole
_users_blackhole_username: "{{ users.blackhole.username | default('no-reply') }}"
_users_blackhole_email: "{{ users.blackhole.email | default(_users_blackhole_username ~ '@' ~ primary_domain) }}"
# Helper Variables for contact user
_users_contact_username: "{{ users.contact.username | default('contact') }}"
_users_contact_email: "{{ users.contact.email | default(_users_contact_username ~ '@' ~ primary_domain) }}"
# Helper Variables for support
_users_support_username: "{{ users.support.username | default('support') }}"
_users_support_email: "{{ users.support.email | default(_users_support_username ~ '@' ~ primary_domain) }}"
# Helper Variables for helpdesk
_users_helpdesk_username: "{{ users.helpdesk.username | default('helpdesk') }}"
_users_helpdesk_email: "{{ users.helpdesk.email | default(_users_helpdesk_username ~ '@' ~ primary_domain) }}"
# Extract SLD and TLD from primary_domain
_users_sld_username: "{{ primary_domain.split('.')[0] }}"
_users_sld_email: "{{ _users_sld_username ~ '@' ~ primary_domain }}"
_users_tld_username: "{{ primary_domain.split('.')[-1] }}"
_users_tld_email: "{{ _users_tld_username ~ '@' ~ primary_domain }}"
# Administrator
default_users:
# Credentials will be used as administration credentials for all applications and the system
administrator:
username: "{{_users_administrator_username}}" # Username of the administrator
email: "{{_users_administrator_email}}" # Email of the administrator
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1001 # Posix User ID
gid: 1001 # Posix Group ID
is_admin: true # Define as admin user
# Account for Newsletter bouncing
bounce:
username: "{{ _users_bounce_username }}" # Bounce-handler account username
email: "{{ _users_bounce_email }}" # Email address for handling bounces
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1002 # Posix User ID for bounce
gid: 1002 # Posix Group ID for bounce
# User to send System Emails from
no-reply:
username: "{{ _users_no_reply_username }}" # No-reply account username
email: "{{ _users_no_reply_email }}" # Email address for outgoing no-reply mails
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1003 # Posix User ID for no-reply
gid: 1003 # Posix Group ID for no-reply
# Emails etc, what you send to this user will be forgetten
blackhole:
username: "{{ _users_blackhole_username }}" # Blackhole account username
email: "{{ _users_blackhole_email }}" # Email address to which emails can be send which well be forgetten
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1004 # Posix User ID for bounce
gid: 1004 # Posix Group ID for bounce
# The contact user account which clients and plattform users can contact
contact:
username: "{{ _users_contact_username }}" # Contact account username
email: "{{ _users_contact_email }}" # Email address to which initial contacct emails can be send
password: "{{ansible_become_password}}" # Example initialisation password needs to be set in inventory file
uid: 1005 # Posix User ID for bounce
gid: 1005 # Posix Group ID for bounce
# Support and Helpdesk accounts
support:
username: "{{ _users_support_username }}" # Support account username
email: "{{ _users_support_email }}" # Email for customer and platform support communication
password: "{{ ansible_become_password }}" # Example initialisation password needs to be set in inventory file
uid: 1006 # Posix User ID for support
gid: 1006 # Posix Group ID for support
helpdesk:
username: "{{ _users_helpdesk_username }}" # Helpdesk account username
email: "{{ _users_helpdesk_email }}" # Email for internal technical helpdesk communication
password: "{{ ansible_become_password }}" # Example initialisation password needs to be set in inventory file
uid: 1007 # Posix User ID for helpdesk
gid: 1007 # Posix Group ID for helpdesk
sld_user:
username: "{{ _users_sld_username }}" # Username based on SLD of the primary domain
email: "{{ _users_sld_email }}" # Email address with SLD username
password: "{{ ansible_become_password }}" # Init password from inventory
uid: 1008
gid: 1008
tld_user:
username: "{{ _users_tld_username }}" # Username based on TLD of the primary domain
email: "{{ _users_tld_email }}" # Email address with TLD username
password: "{{ ansible_become_password }}" # Init password from inventory
uid: 1009
gid: 1009

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
users:
blackhole:
description: "Everything what will be send to this user will disapear"
username: "blackhole"

View File

View File

@@ -21,3 +21,4 @@ galaxy_info:
class: "fa-solid fa-phone"
run_after:
- docker-keycloak
- docker-mailu

View File

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

View File

@@ -50,12 +50,12 @@ ESPOCRM_CONFIG_LOGGER_ROTATION=false
# ------------------------------------------------
ESPOCRM_CONFIG_SMTP_SERVER={{ system_email.host }}
ESPOCRM_CONFIG_SMTP_PORT={{ system_email.port }}
ESPOCRM_CONFIG_SMTP_SECURITY=TLS
ESPOCRM_CONFIG_SMTP_SECURITY={{ "TLS" if system_email.start_tls else "SSL"}}
ESPOCRM_CONFIG_SMTP_AUTH=true
ESPOCRM_CONFIG_SMTP_USERNAME={{ users['no-reply'].email }}
ESPOCRM_CONFIG_SMTP_PASSWORD={{ users['no-reply'].mailu_token }}
ESPOCRM_CONFIG_SMTP_USERNAME={{ users['contact'].email }}
ESPOCRM_CONFIG_SMTP_PASSWORD={{ users['contact'].mailu_token }}
ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_NAME={{ service_provider.company.titel }} - CRM
ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_ADDRESS={{ users['no-reply'].email }}
ESPOCRM_CONFIG_OUTBOUND_EMAIL_FROM_ADDRESS={{ users['contact'].email }}
# ------------------------------------------------
# LDAP settings (optional)

View File

@@ -1,11 +1,5 @@
images:
espocrm: "espocrm/espocrm:latest"
users:
administrator:
username: "{{ users.administrator.username }}"
email: "{{ users.administrator.email }}"
credentials:
features:
matomo: true
css: false
@@ -26,6 +20,8 @@ csp:
connect-src:
- wss://espocrm.{{ primary_domain }}
- "data:"
frame-src:
- https://s.espocrm.com/
domains:
aliases:
- "crm.{{ primary_domain }}"

View File

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

View File

@@ -1,8 +1,5 @@
images:
keycloak: "quay.io/keycloak/keycloak:latest"
users:
administrator:
username: "{{users.administrator.username}}" # Administrator Username for Keycloak
import_realm: True # If True realm will be imported. If false skip.
credentials:
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
hostname: "ldap" # Hostname of the LDAP Server in the central_ldap network
webinterface: "lam" # The webinterface which should be used. Possible: lam and phpldapadmin
users:
administrator:
username: "{{users.administrator.username}}" # Administrator username
credentials:
features:
ldap: true

View File

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

View File

@@ -1,8 +1,5 @@
images:
listmonk: "listmonk/listmonk:latest"
users:
administrator:
username: "{{users.administrator.username}}" # Listmonk administrator account username
public_api_activated: False # Security hole. Can be used for spaming
version: "latest" # Docker Image version
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
users:
administrator:
email: "{{users.administrator.email}}" # Administrator Email for DNS Records
oidc:
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

View File

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

View File

@@ -1,10 +1,6 @@
images:
synapse: "matrixdotorg/synapse:latest"
element: "vectorim/element-web:latest"
# Set bridges
users:
administrator:
username: "{{users.administrator.username}}" # Accountname of the matrix admin
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.
synapse:

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ SMTP_NAME= {{ users['no-reply'].email }}
SMTP_PASSWORD= {{ users['no-reply'].mailu_token }}
# Email from configuration
MAIL_FROM_ADDRESS= "no-reply"
MAIL_FROM_ADDRESS= "{{ users['no-reply'].username }}"
MAIL_DOMAIN= "{{system_email.domain}}"
# Initial Admin Data

View File

@@ -27,9 +27,6 @@ features:
ldap: true
oidc: true
central_database: true
users:
administrator:
username: "{{users.administrator.username}}"
default_quota: '1000000000' # Quota to assign if no quota is specified in the OIDC response (bytes)
legacy_login_mask:
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"
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.
users:
administrator:
email: "{{ users.administrator.email }}" # Initial login email address
oauth2_proxy:
application: "application"
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
users: # Credentials
administrator: # Wordpress administrator
username: "{{users.administrator.username}}" # Username of the wordpress administrator
email: "{{users.administrator.email}}" # Email of the wordpress adminsitrator
plugins:
wp-discourse:
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: "{{users.administrator.username}}"
version: "latest"
oauth2_proxy:
application: "application"

View File

@@ -0,0 +1,9 @@
users:
administrator:
description: "System Administrator"
username: "administrator"
email: "administrator@{{ primary_domain }}"
password: "{{ ansible_become_password }}"
uid: 1001
gid: 1001
is_admin: true

132
roles/user/meta/users.yml Normal file
View File

@@ -0,0 +1,132 @@
# Reserved usernames
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:
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"

View File

@@ -12,7 +12,7 @@ class TestDomainUniqueness(unittest.TestCase):
and assert that no domain appears more than once.
"""
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
if not yaml_file.exists():

View File

@@ -8,7 +8,7 @@ class TestOAuth2ProxyPorts(unittest.TestCase):
def setUpClass(cls):
# Set up root paths and load oauth2_proxy ports mapping
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:
data = yaml.safe_load(f)
cls.oauth2_ports = (
@@ -50,7 +50,7 @@ class TestOAuth2ProxyPorts(unittest.TestCase):
if app_id not in self.oauth2_ports:
self.fail(
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,36 @@
# File: tests/integration/test_unittest_imports.py
import os
import unittest
class TestUnittestImports(unittest.TestCase):
TEST_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
def test_all_test_files_import_unittest(self):
missing = []
for root, dirs, files in os.walk(self.TEST_ROOT):
for filename in files:
if not filename.endswith('.py'):
continue
# only consider test files named like "test_*.py"
if not filename.startswith('test_'):
continue
filepath = os.path.join(root, filename)
with open(filepath, encoding='utf-8') as f:
content = f.read()
# check for either import form
if 'import unittest' not in content and 'from unittest import' not in content:
rel_path = os.path.relpath(filepath, os.getcwd())
missing.append(rel_path)
if missing:
self.fail(
"The following test files do not import unittest:\n" +
"\n".join(f"- {path}" for path in missing)
)
if __name__ == '__main__':
unittest.main()

View File

@@ -2,14 +2,27 @@
import os
import sys
import unittest
# Add module_utils/ to the import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..", "module_utils")))
sys.path.insert(
0,
os.path.abspath(
os.path.join(
os.path.dirname(__file__),
"../../..",
"module_utils",
)
),
)
from module_utils.cert_utils import CertUtils
def test_matches():
tests = [
class TestCertUtilsMatches(unittest.TestCase):
def setUp(self):
# prepare your test cases
self.tests = [
# Exact matches
("example.com", "example.com", True),
("www.example.com", "www.example.com", True),
@@ -32,19 +45,16 @@ def test_matches():
("test.other.com", "*.example.com", False),
]
passed = 0
failed = 0
for domain, san, expected in tests:
def test_matches(self):
for domain, san, expected in self.tests:
with self.subTest(domain=domain, san=san):
result = CertUtils.matches(domain, san)
if result == expected:
print(f"✅ PASS: {domain} vs {san} -> {result}")
passed += 1
else:
print(f"❌ FAIL: {domain} vs {san} -> {result} (expected {expected})")
failed += 1
self.assertEqual(
result,
expected,
msg=f"CertUtils.matches({domain!r}, {san!r}) returned {result}, expected {expected}",
)
print(f"\nSummary: {passed} passed, {failed} failed")
if __name__ == "__main__":
test_matches()
unittest.main()

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")
# 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):
shutil.rmtree(self.temp_dir)

View File

@@ -0,0 +1,136 @@
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_default_lookup_password(self):
"""
When no 'password' override is provided,
the become_pwd lookup template string must be used as the password.
"""
defs = {'frank': {}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
users['frank']['password'],
lookup_template,
"The lookup template string was not correctly applied as the default password"
)
def test_build_users_override_password(self):
"""
When a 'password' override is provided,
that custom password must be used instead of become_pwd.
"""
defs = {'eva': {'password': 'custompw'}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
users['eva']['password'],
'custompw',
"The override password was not correctly applied"
)
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/meta'))
os.makedirs(os.path.join(tmp, 'role2/meta'))
# role1 defines user x
with open(os.path.join(tmp, 'role1/meta/users.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/meta/users.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/meta/users.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()