Optimized code and solved bugs

This commit is contained in:
Kevin Veen-Birkenbach 2025-05-20 04:19:10 +02:00
parent 2f1d6a5178
commit d5dd568994
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
21 changed files with 167 additions and 144 deletions

View File

@ -1,8 +1,8 @@
from ansible.errors import AnsibleFilterError
import os
import sys
import yaml
class FilterModule(object):
def filters(self):
return {
@ -15,48 +15,67 @@ class FilterModule(object):
1) directly in group_names, or
2) the application_id of any role reachable (recursively)
from any group in group_names via meta/dependencies.
Expects:
- applications: dict mapping application_id config
- group_names: list of active role names
"""
# validate inputs
self._validate_inputs(applications, group_names)
roles_dir = self._get_roles_directory()
included_roles = self._collect_reachable_roles(group_names, roles_dir)
included_app_ids = self._gather_application_ids(included_roles, roles_dir)
return self._filter_applications(applications, group_names, included_app_ids)
def _validate_inputs(self, applications, group_names):
"""Validate the inputs for correct types."""
if not isinstance(applications, dict):
raise AnsibleFilterError(f"Expected applications as dict, got {type(applications).__name__}")
if not isinstance(group_names, (list, tuple)):
raise AnsibleFilterError(f"Expected group_names as list/tuple, got {type(group_names).__name__}")
# locate roles directory (assume plugin sits in filter_plugins/)
def _get_roles_directory(self):
"""Locate and return the roles directory."""
plugin_dir = os.path.dirname(__file__)
project_root = os.path.abspath(os.path.join(plugin_dir, '..'))
roles_dir = os.path.join(project_root, 'roles')
# recursively collect all roles reachable from the given groups
def collect_roles(role, seen):
if role in seen:
return
seen.add(role)
meta_file = os.path.join(roles_dir, role, 'meta', 'main.yml')
if not os.path.isfile(meta_file):
return
try:
with open(meta_file) as f:
meta = yaml.safe_load(f) or {}
except Exception:
return
for dep in meta.get('dependencies', []):
if isinstance(dep, str):
dep_name = dep
elif isinstance(dep, dict):
dep_name = dep.get('role') or dep.get('name')
else:
continue
collect_roles(dep_name, seen)
return os.path.join(project_root, 'roles')
def _collect_reachable_roles(self, group_names, roles_dir):
"""Recursively collect all roles reachable from the given groups via meta/dependencies."""
included_roles = set()
for grp in group_names:
collect_roles(grp, included_roles)
for group in group_names:
self._collect_roles_from_group(group, included_roles, roles_dir)
return included_roles
# gather application_ids from those roles
def _collect_roles_from_group(self, group, seen, roles_dir):
"""Recursively collect roles from a specific group."""
if group in seen:
return
seen.add(group)
meta_file = os.path.join(roles_dir, group, 'meta', 'main.yml')
if not os.path.isfile(meta_file):
return
try:
with open(meta_file) as f:
meta = yaml.safe_load(f) or {}
except Exception:
return
for dep in meta.get('dependencies', []):
dep_name = self._get_dependency_name(dep)
if dep_name:
self._collect_roles_from_group(dep_name, seen, roles_dir)
def _get_dependency_name(self, dependency):
"""Extract the dependency role name from the meta data."""
if isinstance(dependency, str):
return dependency
elif isinstance(dependency, dict):
return dependency.get('role') or dependency.get('name')
return None
def _gather_application_ids(self, included_roles, roles_dir):
"""Gather application_ids from the roles."""
included_app_ids = set()
for role in included_roles:
vars_file = os.path.join(roles_dir, role, 'vars', 'main.yml')
@ -67,14 +86,17 @@ class FilterModule(object):
vars_data = yaml.safe_load(f) or {}
except Exception:
continue
app_id = vars_data.get('application_id')
if isinstance(app_id, str) and app_id:
included_app_ids.add(app_id)
# build filtered result: include any application whose key is in group_names or in included_app_ids
return included_app_ids
def _filter_applications(self, applications, group_names, included_app_ids):
"""Filter and return the applications that match the conditions."""
result = {}
for app_key, cfg in applications.items():
if app_key in group_names or app_key in included_app_ids:
result[app_key] = cfg
return result

View File

@ -5,71 +5,79 @@ class FilterModule(object):
return {'canonical_domains_map': self.canonical_domains_map}
def canonical_domains_map(self, apps, primary_domain):
def parse_entry(domains_cfg, key, app_id):
if key not in domains_cfg:
return None
entry = domains_cfg[key]
if isinstance(entry, dict):
values = list(entry.values())
elif isinstance(entry, list):
values = entry
else:
raise AnsibleFilterError(
f"Unexpected type for 'domains.{key}' in application '{app_id}': {type(entry).__name__}"
)
for d in values:
if not isinstance(d, str) or not d.strip():
raise AnsibleFilterError(
f"Invalid domain entry in '{key}' for application '{app_id}': {d!r}"
)
return values
"""
Maps applications to their canonical domains, checking for conflicts
and ensuring all domains are valid and unique across applications.
"""
result = {}
seen = {}
seen_domains = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('domains')
if not domains_cfg or 'canonical' not in domains_cfg:
default = f"{app_id}.{primary_domain}"
if default in seen:
raise AnsibleFilterError(
f"Domain '{default}' is already configured for '{seen[default]}' and '{app_id}'"
)
seen[default] = app_id
result[app_id] = [default]
self._add_default_domain(app_id, primary_domain, seen_domains, result)
continue
entry = domains_cfg['canonical']
if isinstance(entry, dict):
for name, domain in entry.items():
if not isinstance(domain, str) or not domain.strip():
raise AnsibleFilterError(
f"Invalid domain entry in 'canonical' for application '{app_id}': {domain!r}"
)
if domain in seen:
raise AnsibleFilterError(
f"Domain '{domain}' is already configured for '{seen[domain]}' and '{app_id}'"
)
seen[domain] = app_id
result[app_id] = entry.copy()
elif isinstance(entry, list):
for domain in entry:
if not isinstance(domain, str) or not domain.strip():
raise AnsibleFilterError(
f"Invalid domain entry in 'canonical' for application '{app_id}': {domain!r}"
)
if domain in seen:
raise AnsibleFilterError(
f"Domain '{domain}' is already configured for '{seen[domain]}' and '{app_id}'"
)
seen[domain] = app_id
result[app_id] = list(entry)
else:
raise AnsibleFilterError(
f"Unexpected type for 'domains.canonical' in application '{app_id}': {type(entry).__name__}"
)
canonical_domains = domains_cfg['canonical']
self._process_canonical_domains(app_id, canonical_domains, seen_domains, result)
return result
def _add_default_domain(self, app_id, primary_domain, seen_domains, result):
"""
Add the default domain for an application if no canonical domains are defined.
Ensures the domain is unique across applications.
"""
default_domain = f"{app_id}.{primary_domain}"
if default_domain in seen_domains:
raise AnsibleFilterError(
f"Domain '{default_domain}' is already configured for "
f"'{seen_domains[default_domain]}' and '{app_id}'"
)
seen_domains[default_domain] = app_id
result[app_id] = [default_domain]
def _process_canonical_domains(self, app_id, canonical_domains, seen_domains, result):
"""
Process the canonical domains for an application, handling both lists and dicts,
and ensuring each domain is unique.
"""
if isinstance(canonical_domains, dict):
self._process_canonical_domains_dict(app_id, canonical_domains, seen_domains, result)
elif isinstance(canonical_domains, list):
self._process_canonical_domains_list(app_id, canonical_domains, seen_domains, result)
else:
raise AnsibleFilterError(
f"Unexpected type for 'domains.canonical' in application '{app_id}': "
f"{type(canonical_domains).__name__}"
)
def _process_canonical_domains_dict(self, app_id, domains_dict, seen_domains, result):
"""
Process a dictionary of canonical domains for an application.
"""
for name, domain in domains_dict.items():
self._validate_and_check_domain(app_id, domain, seen_domains)
result[app_id] = domains_dict.copy()
def _process_canonical_domains_list(self, app_id, domains_list, seen_domains, result):
"""
Process a list of canonical domains for an application.
"""
for domain in domains_list:
self._validate_and_check_domain(app_id, domain, seen_domains)
result[app_id] = list(domains_list)
def _validate_and_check_domain(self, app_id, domain, seen_domains):
"""
Validate the domain and check if it has already been assigned to another application.
"""
if not isinstance(domain, str) or not domain.strip():
raise AnsibleFilterError(
f"Invalid domain entry in 'canonical' for application '{app_id}': {domain!r}"
)
if domain in seen_domains:
raise AnsibleFilterError(
f"Domain '{domain}' is already configured for '{seen_domains[domain]}' and '{app_id}'"
)
seen_domains[domain] = app_id

View File

@ -30,10 +30,4 @@ defaults_service_provider:
legal:
editorial_responsible: "Johannes Gutenberg"
source_code: "https://github.com/kevinveenbirkenbach/cymais"
imprint: >-
{{ "{protocol}://{domain}/imprint.html"
| safe_placeholders({
'protocol': web_protocol,
'domain': domains.html_server
})
}}
imprint: "{{web_protocol}}://{{domains['html-server']}}/imprint.html"

View File

@ -1,8 +1,3 @@
- name: "Debug: cloudflare_domains"
debug:
var: cloudflare_domains
when: enable_debug
- name: Create or update Cloudflare A-record for {{ item }}
community.general.cloudflare_dns:
api_token: "{{ cloudflare_api_token }}"

View File

@ -283,7 +283,7 @@ HELP_URL=https://docs.bigbluebutton.org/greenlight/gl-overview.html
# approval - For approve/decline registration
DEFAULT_REGISTRATION=invite
{% if applications[application_id].features.oidc | bool %}
{% if applications | is_feature_enabled('oidc',application_id) %}
### EXTERNAL AUTHENTICATION METHODS
# @See https://docs.bigbluebutton.org/greenlight/v3/external-authentication/
#

View File

@ -118,7 +118,7 @@ run:
## If you want to set the 'From' email address for your first registration, uncomment and change:
## After getting the first signup email, re-comment the line. It only needs to run once.
#- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
{% if applications[application_id].features.oidc | bool %}
{% if applications | is_feature_enabled('oidc',application_id) %}
# Deactivate Default Login
- exec: rails r "SiteSetting.enable_local_logins = false"
- exec: rails r "SiteSetting.enable_passkeys = false" # https://meta.discourse.org/t/passwordless-login-using-passkeys/285589

View File

@ -77,7 +77,7 @@ ESPOCRM_CONFIG_LDAP_USER_LOGIN_FILTER=(sAMAccountName=%USERNAME%)
# OpenID Connect settings (optional)
# Applied only if the feature flag is true
# ------------------------------------------------
{% if applications[application_id].features.oidc | bool %}
{% if applications | is_feature_enabled('oidc',application_id) %}
# ------------------------------------------------
# OpenID Connect settings

View File

@ -17,7 +17,7 @@ listmonk_settings:
"provider_url": oidc.client.issuer_url,
"client_secret": oidc.client.secret
} | to_json }}
when: applications[application_id].features.oidc | bool
when: applications | is_feature_enabled('oidc',application_id)
# hCaptcha toggles and credentials
- key: "security.enable_captcha"

View File

@ -158,7 +158,7 @@ API_TOKEN={{applications.mailu.credentials.api_token}}
AUTH_REQUIRE_TOKENS=True
{% if applications[application_id].features.oidc | bool %}
{% if applications | is_feature_enabled('oidc',application_id) %}
###################################
# OpenID Connect settings
###################################

View File

@ -8,7 +8,7 @@ cert_mount_directory: "{{docker_compose.directories.volumes}}certs/"
# Use dedicated source for oidc if activated
# @see https://github.com/heviat/Mailu-OIDC/tree/2024.06
docker_source: "{{ 'ghcr.io/heviat' if applications[application_id].features.oidc | bool else 'ghcr.io/mailu' }}"
docker_source: "{{ 'ghcr.io/heviat' if applications | is_feature_enabled('oidc',application_id) else 'ghcr.io/mailu' }}"
domain: "{{ domains | get_domain(application_id) }}"
http_port: "{{ ports.localhost.http[application_id] }}"

View File

@ -52,7 +52,7 @@ SMTP_OPENSSL_VERIFY_MODE=none
SMTP_ENABLE_STARTTLS=auto
SMTP_FROM_ADDRESS=Mastodon <{{ users['no-reply'].email }}>
{% if applications[application_id].features.oidc | bool %}
{% if applications | is_feature_enabled('oidc',application_id) %}
###################################
# OpenID Connect settings
###################################

View File

@ -20,8 +20,6 @@ oidc:
# @see https://apps.nextcloud.com/apps/sociallogin
flavor: "oidc_login" # Keeping on sociallogin because the other option is not implemented yet
credentials:
# database_password: Null # Needs to be set in inventory file
# administrator_password: None # Keep in mind to change the password fast after creation and activate 2FA
features:
matomo: true
css: true

View File

@ -1,15 +1,15 @@
http_address = "0.0.0.0:4180"
cookie_secret = "{{ applications[oauth2_proxy_application_id].credentials.oauth2_proxy_cookie_secret }}"
email_domains = "{{ primary_domain }}"
cookie_secure = "true" # True is necessary to force the cookie set via https
cookie_secure = "true" # True is necessary to force the cookie set via https
upstreams = "http://{{ applications[oauth2_proxy_application_id].oauth2_proxy.application }}:{{ applications[oauth2_proxy_application_id].oauth2_proxy.port }}"
cookie_domains = ["{{ domains[oauth2_proxy_application_id] }}", "{{ domains | get_domain('keycloak') }}"] # Required so cookie can be read on all subdomains.
whitelist_domains = [".{{ primary_domain }}"] # Required to allow redirection back to original requested target.
cookie_domains = ["{{ domains | get_domain(oauth2_proxy_application_id) }}", "{{ domains | get_domain('keycloak') }}"] # Required so cookie can be read on all subdomains.
whitelist_domains = [".{{ primary_domain }}"] # Required to allow redirection back to original requested target.
# keycloak provider
client_secret = "{{ oidc.client.secret }}"
client_id = "{{ oidc.client.id }}"
redirect_url = "{{ web_protocol }}://{{domains[oauth2_proxy_application_id]}}/oauth2/callback"
redirect_url = "{{ web_protocol }}://{{ domains | get_domain(oauth2_proxy_application_id) }}/oauth2/callback"
oidc_issuer_url = "{{ oidc.client.issuer_url }}"
provider = "oidc"
provider_display_name = "Keycloak"

View File

@ -17,6 +17,8 @@ csp:
flags:
script-src:
unsafe-inline: true
style-src:
unsafe-inline: true
domains:
canonical:
- "project.{{ primary_domain }}"

View File

@ -1,5 +1,5 @@
application_id: "pgadmin"
database_type: "postgres"
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id)"
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id) }}"
pgadmin_user: 5050
pgadmin_group: "{{pgadmin_user}}"

View File

@ -1,3 +1,3 @@
application_id: "phpmyadmin"
database_type: "mariadb"
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id)"
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id) }}"

View File

@ -47,7 +47,7 @@ for filename in os.listdir(config_path):
# Prepare the URL and expected status codes
url = f"{{ web_protocol }}://{domain}"
redirected_domains = [domain['source'] for domain in {{current_play_redirect_domain_mappings}}]
redirected_domains = [domain['source'] for domain in {{ current_play_domain_mappings_redirect}}]
{%- if domains.mailu | safe_var | bool %}
redirected_domains.append("{{domains | get_domain('mailu')}}")
{%- endif %}

View File

@ -1,2 +1,2 @@
application_id: "html_server"
application_id: "html-server"
domain: "{{domains | get_domain(application_id)}}"

View File

@ -9,14 +9,17 @@
set_fact:
system_email: "{{ default_system_email | combine(system_email | default({}, true), recursive=True) }}"
- name: Merge application definitions
set_fact:
applications: "{{ defaults_applications | combine(applications | default({}, true), recursive=True) }}"
- name: Merge current play applications
set_fact:
current_play_applications: >-
{{
defaults_applications |
combine(applications | default({}, true), recursive=True) |
applications |
applications_if_group_and_deps(group_names)
}}
}}
- name: Merge current play domain definitions
set_fact:
@ -26,28 +29,29 @@
combine(domains | default({}, true), recursive=True)
}}
- name: Set current play all domains incl. www redirect if enabled
set_fact:
current_play_domains_all: >-
{{
current_play_domains |
generate_all_domains(
('www_redirect' in group_names)
)
}}
- name: Set current play redirect domain mappings
set_fact:
current_play_redirect_domain_mappings: >-
current_play_domain_mappings_redirect: >-
{{
current_play_applications |
domain_mappings(primary_domain) |
merge_mapping(redirect_domain_mappings, 'source')
}}
- name: Merge application definitions
- name: Set current play all domains incl. www redirect if enabled
set_fact:
applications: "{{ defaults_applications | combine(applications | default({}, true), recursive=True) }}"
current_play_domains_all: >-
{{
(current_play_domains |
combine(
current_play_domain_mappings_redirect |
items2dict(key_name='target', value_name='source'),
recursive=True
)) |
generate_all_domains(
('www_redirect' in group_names)
)
}}
- name: Merge domain definitions for all domains
set_fact:

View File

@ -32,7 +32,7 @@
include_role:
name: nginx-redirect-domains
vars:
domain_mappings: "{{current_play_redirect_domain_mappings}}"
domain_mappings: "{{ current_play_domain_mappings_redirect}}"
- name: setup www redirect
when: ("www_redirect" in group_names)

View File

@ -15,9 +15,9 @@ class TestLoadConfigurationFilter(unittest.TestCase):
def setUp(self):
_cfg_cache.clear()
self.f = FilterModule().filters()['load_configuration']
self.app = 'html_server'
self.app = 'html-server'
self.nested_cfg = {
'html_server': {
'html-server': {
'features': {'matomo': True},
'domains': {'canonical': ['html.example.com']}
}
@ -76,8 +76,8 @@ class TestLoadConfigurationFilter(unittest.TestCase):
@patch('load_configuration.os.listdir', return_value=['r1'])
@patch('load_configuration.os.path.isdir', return_value=True)
@patch('load_configuration.os.path.exists', return_value=True)
@patch('load_configuration.open', mock_open(read_data="html_server: {}"))
@patch('load_configuration.yaml.safe_load', return_value={'html_server': {}})
@patch('load_configuration.open', mock_open(read_data="html-server: {}"))
@patch('load_configuration.yaml.safe_load', return_value={'html-server': {}})
def test_key_not_found_after_load(self, *_):
with self.assertRaises(AnsibleFilterError):
self.f(self.app, 'does.not.exist')