Compare commits

..

3 Commits

72 changed files with 1135 additions and 314 deletions

View File

@ -1,5 +1,5 @@
ROLES_DIR := ./roles
APPLICATIONS_OUT := ./group_vars/all/11_applications.yml
APPLICATIONS_OUT := ./group_vars/all/03_applications.yml
APPLICATIONS_SCRIPT := ./cli/generate-applications-defaults.py
INCLUDES_OUT := ./tasks/include-docker-roles.yml
INCLUDES_SCRIPT := ./cli/generate-role-includes.py

View File

@ -1,2 +1,3 @@
[defaults]
lookup_plugins = ./lookup_plugins
filter_plugins = ./filter_plugins

View File

@ -15,7 +15,7 @@ def load_yaml_file(path):
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/11_applications.yml", help="Path to output YAML file")
parser.add_argument("--output-file", default="group_vars/all/03_applications.yml", help="Path to output YAML file")
args = parser.parse_args()
cwd = Path.cwd()

View File

@ -0,0 +1,111 @@
## 📖 CyMaIS.Cloud Ansible & Python Directory Guide
This document provides a **decision matrix** for when to use each default Ansible plugin and module directory in the context of **CyMaIS.Cloud development** with Ansible and Python. It links to official docs, explains use-cases, and points back to our conversation.
---
### 🔗 Links & References
* Official Ansible Plugin Guide: [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html)
* Official Ansible Module Guide: [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_modules.html](https://docs.ansible.com/ansible/latest/dev_guide/developing_modules.html)
* This conversation: [Link to this conversation](https://chat.openai.com/)
---
### 🛠️ Repo Layout & Default Directories
```plaintext
ansible-repo/
├── library/ # 📦 Custom Ansible modules
├── filter_plugins/ # 🔍 Custom Jinja2 filters
├── lookup_plugins/ # 👉 Custom lookup plugins
├── module_utils/ # 🛠️ Shared Python helpers for modules
├── action_plugins/ # ⚙️ Task-level orchestration logic
├── callback_plugins/ # 📣 Event callbacks (logging, notifications)
├── inventory_plugins/ # 🌐 Dynamic inventory sources
├── strategy_plugins/ # 🧠 Task execution strategies
└── ... # Other plugin dirs (connection, cache, etc.)
```
---
### 🎯 Decision Matrix: Which Folder for What?
| Folder | Type | Use-Case | Example (CyMaIS.Cloud) | Emoji |
| -------------------- | -------------------- | ---------------------------------------- | ----------------------------------------------------- | ----- |
| `library/` | **Module** | Write idempotent actions | `cloud_network.py`: manage VPCs, subnets | 📦 |
| `filter_plugins/` | **Filter plugin** | Jinja2 data transforms in templates/vars | `to_camel_case.py`: convert keys for API calls | 🔍 |
| `lookup_plugins/` | **Lookup plugin** | Fetch external/secure data at runtime | `vault_lookup.py`: pull secrets from CyMaIS Vault | 👉 |
| `module_utils/` | **Utility library** | Shared Python code for modules | `cymais_client.py`: common API client base class | 🛠️ |
| `action_plugins/` | **Action plugin** | Complex task orchestration wrappers | `deploy_stack.py`: sequence Terraform + Ansible steps | ⚙️ |
| `callback_plugins/` | **Callback plugin** | Customize log/report behavior | `notify_slack.py`: send playbook status to Slack | 📣 |
| `inventory_plugins/` | **Inventory plugin** | Dynamic host/group sources | `azure_inventory.py`: list hosts from Azure tags | 🌐 |
| `strategy_plugins/` | **Strategy plugin** | Control task execution order/parallelism | `rolling_batch.py`: phased rollout of VMs | 🧠 |
---
### 📝 Detailed Guidance
1. **library/** 📦
* **When?** Implement **one-off, idempotent actions** (create/delete cloud resources).
* **Why?** Modules under `library/` are first in search path for `ansible` modules.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_modules.html](https://docs.ansible.com/ansible/latest/dev_guide/developing_modules.html)
2. **filter\_plugins/** 🔍
* **When?** You need **data manipulation** (lists, strings, dicts) inside Jinja2.
* **Why?** Extends `|` filters in templates and variable declarations.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html#filter-plugins](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#filter-plugins)
3. **lookup\_plugins/** 👉
* **When?** You must **retrieve secret/external data** during playbook compile/runtime.
* **Why?** Lookup plugins run before tasks, enabling dynamic variable resolution.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html#lookup-plugins](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#lookup-plugins)
4. **module\_utils/** 🛠️
* **When?** Multiple modules share **common Python code** (HTTP clients, validation).
* **Why?** Avoid code duplication; modules import these utilities.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_modules.html#module-utils](https://docs.ansible.com/ansible/latest/dev_guide/developing_modules.html#module-utils)
5. **action\_plugins/** ⚙️
* **When?** You need to **wrap or extend** module behavior at task invocation time.
* **Why?** Provides hooks before/after module execution.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html#action-plugins](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#action-plugins)
6. **callback\_plugins/** 📣
* **When?** You want **custom event handlers** (logging, progress, notifications).
* **Why?** Receive play/task events for custom output.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html#callback-plugins](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#callback-plugins)
7. **inventory\_plugins/** 🌐
* **When?** Hosts/groups come from **dynamic sources** (cloud APIs, databases).
* **Why?** Replace static `inventory.ini` with code-driven inventories.
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html#inventory-plugins](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#inventory-plugins)
8. **strategy\_plugins/** 🧠
* **When?** You need to **customize execution strategy** (parallelism, ordering).
* **Why?** Override default `linear` strategy (e.g., `free`, custom batches).
* **Docs:** [https://docs.ansible.com/ansible/latest/dev\_guide/developing\_plugins.html#strategy-plugins](https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html#strategy-plugins)
---
### 🚀 CyMaIS.Cloud Best Practices
* **Organize modules** by service under `library/cloud/` (e.g., `vm`, `network`, `storage`).
* **Shared client code** in `module_utils/cymais/` for authentication, request handling.
* **Secrets lookup** via `lookup_plugins/vault_lookup.py` pointing to CyMaIS Vault.
* **Filters** to normalize data formats from cloud APIs (e.g., `snake_to_camel`).
* **Callbacks** to stream playbook results into CyMaIS Monitoring.
Use this matrix as your **single source of truth** when extending Ansible for CyMaIS.Cloud! 👍
---
This matrix was created with the help of ChatGPT 🤖—see our conversation [here](https://chatgpt.com/canvas/shared/682b1a62d6dc819184ecdc696c51290a).

View File

@ -0,0 +1,86 @@
from ansible.errors import AnsibleFilterError
class FilterModule(object):
def filters(self):
return {'alias_domains_map': self.alias_domains_map}
def alias_domains_map(self, apps, primary_domain):
"""
Build a map of application IDs to their alias domains.
- If no `domains` key []
- If `domains` exists but is an empty dict return the original cfg
- Explicit `aliases` are used (default appended if missing)
- If only `canonical` defined and it doesn't include default, default is added
- Invalid types raise AnsibleFilterError
"""
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
def default_domain(app_id, primary):
return f"{app_id}.{primary}"
# 1) Precompute canonical domains per app (fallback to default)
canonical_map = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('domains') or {}
entry = domains_cfg.get('canonical')
if entry is None:
canonical_map[app_id] = [default_domain(app_id, primary_domain)]
elif isinstance(entry, dict):
canonical_map[app_id] = list(entry.values())
elif isinstance(entry, list):
canonical_map[app_id] = list(entry)
else:
raise AnsibleFilterError(
f"Unexpected type for 'domains.canonical' in application '{app_id}': {type(entry).__name__}"
)
# 2) Build alias list per app
result = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('domains')
# no domains key → no aliases
if domains_cfg is None:
result[app_id] = []
continue
# empty domains dict → return the original cfg
if isinstance(domains_cfg, dict) and not domains_cfg:
result[app_id] = cfg
continue
# otherwise, compute aliases
aliases = parse_entry(domains_cfg, 'aliases', app_id) or []
default = default_domain(app_id, primary_domain)
has_aliases = 'aliases' in domains_cfg
has_canon = 'canonical' in domains_cfg
if has_aliases:
if default not in aliases:
aliases.append(default)
elif has_canon:
canon = canonical_map.get(app_id, [])
if default not in canon and default not in aliases:
aliases.append(default)
result[app_id] = aliases
return result

View File

@ -0,0 +1,75 @@
from ansible.errors import AnsibleFilterError
class FilterModule(object):
def filters(self):
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
result = {}
seen = {}
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]
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__}"
)
return result

View File

@ -1,6 +1,6 @@
def is_feature_enabled(applications, feature: str, application_id: str) -> bool:
def is_feature_enabled(applications: dict, feature: str, application_id: str) -> bool:
"""
Check if a generic feature is enabled for the given application.
Return True if applications[application_id].features[feature] is truthy.
"""
app = applications.get(application_id, {})
return bool(app.get('features', {}).get(feature, False))

View File

@ -12,9 +12,10 @@ class FilterModule(object):
'build_csp_header': self.build_csp_header,
}
def is_feature_enabled(self, applications, feature: str, application_id: str) -> bool:
@staticmethod
def is_feature_enabled(applications: dict, feature: str, application_id: str) -> bool:
"""
Check if a generic feature is enabled for the given application.
Return True if applications[application_id].features[feature] is truthy.
"""
app = applications.get(application_id, {})
return bool(app.get('features', {}).get(feature, False))

View File

@ -1,69 +0,0 @@
import re
from ansible.errors import AnsibleFilterError
class FilterModule(object):
"""
Custom Ansible filter plugin:
- generate_all_domains: Flatten, dedupe, sort domains with optional www prefixes
- generate_base_sld_domains: Extract unique sld.tld domains from values and redirect sources
"""
def filters(self):
return {
'generate_all_domains': self.generate_all_domains,
'generate_base_sld_domains': self.generate_base_sld_domains,
}
@staticmethod
def generate_all_domains(domains_dict, include_www=True):
"""
Transform a dict of domains (values: str, list, dict) into a flat list,
optionally add 'www.' prefixes, dedupe and sort alphabetically.
Avoids infinite loops by snapshotting initial domain list for www prefixes.
"""
try:
flat = FilterModule._flatten_domain_values(domains_dict)
if include_www:
# Snapshot original list to avoid extending while iterating
original = list(flat)
flat.extend([f"www.{d}" for d in original])
return sorted(set(flat))
except Exception as exc:
raise AnsibleFilterError(f"generate_all_domains failed: {exc}")
@staticmethod
def generate_base_sld_domains(domains_dict, redirect_mappings):
"""
Flatten domains_dict and redirect_mappings, extract second-level + top-level domains.
redirect_mappings: list of dicts with key 'source'
"""
try:
flat = FilterModule._flatten_domain_values(domains_dict)
for mapping in redirect_mappings or []:
src = mapping.get('source')
if isinstance(src, str):
flat.append(src)
elif isinstance(src, list):
flat.extend(src)
pattern = re.compile(r'^(?:.*\.)?([^.]+\.[^.]+)$')
slds = {m.group(1) for d in flat if (m := pattern.match(d))}
return sorted(slds)
except Exception as exc:
raise AnsibleFilterError(f"generate_base_sld_domains failed: {exc}")
@staticmethod
def _flatten_domain_values(domains_dict):
"""
Helper to extract domain strings from dict values (str, list, dict).
"""
flat = []
for val in (domains_dict or {}).values():
if isinstance(val, str):
flat.append(val)
elif isinstance(val, list):
flat.extend(val)
elif isinstance(val, dict):
flat.extend(val.values())
return flat

View File

@ -0,0 +1,94 @@
from ansible.errors import AnsibleFilterError
class FilterModule(object):
def filters(self):
return {'domain_mappings': self.domain_mappings}
def domain_mappings(self, apps, primary_domain):
"""
Build a flat list of redirect mappings for all apps:
- source: each alias domain
- target: the first canonical domain (app.domains.canonical[0] or default)
Logic for computing aliases and canonicals is identical to alias_domains_map + canonical_domains_map.
"""
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
def default_domain(app_id, primary):
return f"{app_id}.{primary}"
# 1) Compute canonical domains per app (always as a list)
canonical_map = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('domains') or {}
entry = domains_cfg.get('canonical')
if entry is None:
canonical_map[app_id] = [default_domain(app_id, primary_domain)]
elif isinstance(entry, dict):
canonical_map[app_id] = list(entry.values())
elif isinstance(entry, list):
canonical_map[app_id] = list(entry)
else:
raise AnsibleFilterError(
f"Unexpected type for 'domains.canonical' in application '{app_id}': {type(entry).__name__}"
)
# 2) Compute alias domains per app
alias_map = {}
for app_id, cfg in apps.items():
domains_cfg = cfg.get('domains')
if domains_cfg is None:
# no domains key → no aliases
alias_map[app_id] = []
continue
if isinstance(domains_cfg, dict) and not domains_cfg:
# empty domains dict → only default
alias_map[app_id] = [default_domain(app_id, primary_domain)]
continue
aliases = parse_entry(domains_cfg, 'aliases', app_id) or []
default = default_domain(app_id, primary_domain)
has_aliases = 'aliases' in domains_cfg
has_canonical = 'canonical' in domains_cfg
if has_aliases:
if default not in aliases:
aliases.append(default)
elif has_canonical:
canon = canonical_map.get(app_id, [])
if default not in canon and default not in aliases:
aliases.append(default)
alias_map[app_id] = aliases
# 3) Build flat list of {source, target} entries
mappings = []
for app_id, sources in alias_map.items():
# pick first canonical domain as target
canon_list = canonical_map.get(app_id, [])
target = canon_list[0] if canon_list else default_domain(app_id, primary_domain)
for src in sources:
mappings.append({
'source': src,
'target': target
})
return mappings

View File

@ -0,0 +1,31 @@
from ansible.errors import AnsibleFilterError
class FilterModule(object):
def filters(self):
return {'generate_all_domains': self.generate_all_domains}
def generate_all_domains(self, domains_dict, include_www=True):
"""
Transform a dict of domains (values: str, list, dict) into a flat list,
optionally add 'www.' prefixes, dedupe and sort alphabetically.
"""
# lokaler Helfer zum Flatten
def _flatten(domains):
flat = []
for v in (domains or {}).values():
if isinstance(v, str):
flat.append(v)
elif isinstance(v, list):
flat.extend(v)
elif isinstance(v, dict):
flat.extend(v.values())
return flat
try:
flat = _flatten(domains_dict)
if include_www:
original = list(flat)
flat.extend([f"www.{d}" for d in original])
return sorted(set(flat))
except Exception as exc:
raise AnsibleFilterError(f"generate_all_domains failed: {exc}")

View File

@ -0,0 +1,37 @@
import re
from ansible.errors import AnsibleFilterError
class FilterModule(object):
def filters(self):
return {'generate_base_sld_domains': self.generate_base_sld_domains}
def generate_base_sld_domains(self, domains_dict, redirect_mappings):
"""
Flatten domains_dict und redirect_mappings, extrahiere SLDs (z.B. example.com),
dedupe und sortiere.
"""
def _flatten(domains):
flat = []
for v in (domains or {}).values():
if isinstance(v, str):
flat.append(v)
elif isinstance(v, list):
flat.extend(v)
elif isinstance(v, dict):
flat.extend(v.values())
return flat
try:
flat = _flatten(domains_dict)
for mapping in redirect_mappings or []:
src = mapping.get('source')
if isinstance(src, str):
flat.append(src)
elif isinstance(src, list):
flat.extend(src)
pattern = re.compile(r'^(?:.*\.)?([^.]+\.[^.]+)$')
slds = {m.group(1) for d in flat if (m := pattern.match(d))}
return sorted(slds)
except Exception as exc:
raise AnsibleFilterError(f"generate_base_sld_domains failed: {exc}")

View File

@ -1,16 +1,9 @@
from ansible.errors import AnsibleFilterError
import sys
import os
import yaml
from ansible.errors import AnsibleFilterError
class FilterModule(object):
"""
Custom filters for conditional domain assignments, handling both direct group matches
and recursive role dependency resolution.
Determines if a given application_id (domain_key) should have its domain added by checking:
- If domain_key is explicitly listed in group_names, or
- If domain_key matches any application_id of roles reachable from active groups via dependencies.
"""
def filters(self):
return {
@ -39,9 +32,27 @@ class FilterModule(object):
result[domain_key] = domain_value
return result
# Setup roles directory path
# Determine plugin directory based on filter plugin module if available
plugin_dir = None
for module in sys.modules.values():
fm = getattr(module, 'FilterModule', None)
if fm is not None:
try:
# Access staticmethod, compare underlying function
if getattr(fm, 'add_domain_if_group') is DomainFilterUtil.add_domain_if_group:
plugin_dir = os.path.dirname(module.__file__)
break
except Exception:
continue
if plugin_dir:
# The plugin_dir is the filter_plugins directory; project_root is one level up
project_root = os.path.abspath(os.path.join(plugin_dir, '..'))
else:
# Fallback: locate project root relative to this utility file
plugin_dir = os.path.dirname(__file__)
project_root = os.path.abspath(os.path.join(plugin_dir, '..'))
roles_dir = os.path.join(project_root, 'roles')
# Collect all roles reachable from the active groups

View File

@ -1,73 +0,0 @@
defaults_domains: >-
{{ {}
| add_domain_if_group('akaunting', 'accounting.' ~ primary_domain, group_names)
| add_domain_if_group('attendize', 'tickets.' ~ primary_domain, group_names)
| add_domain_if_group('baserow', 'baserow.' ~ primary_domain, group_names)
| add_domain_if_group('bigbluebutton', 'meet.' ~ primary_domain, group_names)
| add_domain_if_group('bluesky', {'web': 'bskyweb.' ~ primary_domain,'api':'bluesky.' ~ primary_domain}, group_names)
| add_domain_if_group('discourse', 'forum.' ~ primary_domain, group_names)
| add_domain_if_group('elk', 'elk.' ~ primary_domain, group_names)
| add_domain_if_group('espocrm', 'espocrm.' ~ primary_domain, group_names)
| add_domain_if_group('file_server', 'files.' ~ primary_domain, group_names)
| add_domain_if_group('friendica', 'friendica.' ~ primary_domain, group_names)
| add_domain_if_group('funkwhale', 'music.' ~ primary_domain, group_names)
| add_domain_if_group('gitea', 'git.' ~ primary_domain, group_names)
| add_domain_if_group('gitlab', 'gitlab.' ~ primary_domain, group_names)
| add_domain_if_group('html_server', 'html.' ~ primary_domain, group_names)
| add_domain_if_group('keycloak', 'auth.' ~ primary_domain, group_names)
| add_domain_if_group('lam', 'lam.' ~ primary_domain, group_names)
| add_domain_if_group('ldap', 'ldap.' ~ primary_domain, group_names)
| add_domain_if_group('listmonk', 'newsletter.' ~ primary_domain, group_names)
| add_domain_if_group('mailu', 'mail.' ~ primary_domain, group_names)
| add_domain_if_group('mastodon', ['microblog.' ~ primary_domain], group_names)
| add_domain_if_group('matomo', 'matomo.' ~ primary_domain, group_names)
| add_domain_if_group('matrix', {'synapse': 'matrix.' ~ primary_domain, 'element':'element.' ~ primary_domain}, group_names)
| add_domain_if_group('moodle', 'academy.' ~ primary_domain, group_names)
| add_domain_if_group('mediawiki', 'wiki.' ~ primary_domain, group_names)
| add_domain_if_group('nextcloud', 'cloud.' ~ primary_domain, group_names)
| add_domain_if_group('openproject', 'project.' ~ primary_domain, group_names)
| add_domain_if_group('peertube', ['video.' ~ primary_domain], group_names)
| add_domain_if_group('pgadmin', 'pgadmin.' ~ primary_domain, group_names)
| add_domain_if_group('phpmyadmin', 'phpmyadmin.' ~ primary_domain, group_names)
| add_domain_if_group('phpmyldapadmin', 'phpmyldap.' ~ primary_domain, group_names)
| add_domain_if_group('pixelfed', 'picture.' ~ primary_domain, group_names)
| add_domain_if_group('portfolio', primary_domain, group_names)
| add_domain_if_group('presentation', 'slides.' ~ primary_domain, group_names)
| add_domain_if_group('roulette-wheel', 'roulette.' ~ primary_domain, group_names)
| add_domain_if_group('snipe_it', 'inventory.' ~ primary_domain, group_names)
| add_domain_if_group('sphinx', 'docs.' ~ primary_domain, group_names)
| add_domain_if_group('syncope', 'syncope.' ~ primary_domain, group_names)
| add_domain_if_group('taiga', 'kanban.' ~ primary_domain, group_names)
| add_domain_if_group('yourls', 's.' ~ primary_domain, group_names)
| add_domain_if_group('wordpress', ['blog.' ~ primary_domain], group_names)
}}
defaults_redirect_domain_mappings: >-
{{ []
| add_redirect_if_group('akaunting', "akaunting." ~ primary_domain, domains.akaunting, group_names)
| add_redirect_if_group('bigbluebutton', "bbb." ~ primary_domain, domains.bigbluebutton, group_names)
| add_redirect_if_group('discourse', "discourse." ~ primary_domain, domains.discourse, group_names)
| add_redirect_if_group('espocrm', "crm." ~ primary_domain, domains.espocrm, group_names)
| add_redirect_if_group('funkwhale', "funkwhale." ~ primary_domain, domains.funkwhale, group_names)
| add_redirect_if_group('gitea', "gitea." ~ primary_domain, domains.gitea, group_names)
| add_redirect_if_group('keycloak', "keycloak." ~ primary_domain, domains.keycloak, group_names)
| add_redirect_if_group('lam', domains.ldap, domains.lam, group_names)
| add_redirect_if_group('phpmyldapadmin', domains.ldap, domains.phpmyldapadmin,group_names)
| add_redirect_if_group('listmonk', "listmonk." ~ primary_domain, domains.listmonk, group_names)
| add_redirect_if_group('mailu', "mailu." ~ primary_domain, domains.mailu, group_names)
| add_redirect_if_group('mastodon', "mastodon." ~ primary_domain, domains.mastodon[0], group_names)
| add_redirect_if_group('moodle', "moodle." ~ primary_domain, domains.moodle, group_names)
| add_redirect_if_group('nextcloud', "nextcloud." ~ primary_domain, domains.nextcloud, group_names)
| add_redirect_if_group('openproject', "openproject." ~ primary_domain, domains.openproject, group_names)
| add_redirect_if_group('peertube', "peertube." ~ primary_domain, domains.peertube[0], group_names)
| add_redirect_if_group('pixelfed', "pictures." ~ primary_domain, domains.pixelfed, group_names)
| add_redirect_if_group('pixelfed', "pixelfed." ~ primary_domain, domains.pixelfed, group_names)
| add_redirect_if_group('yourls', "short." ~ primary_domain, domains.yourls, group_names)
| add_redirect_if_group('snipe-it', "snipe-it." ~ primary_domain, domains.snipe_it, group_names)
| add_redirect_if_group('taiga', "taiga." ~ primary_domain, domains.taiga, group_names)
| add_redirect_if_group('peertube', "videos." ~ primary_domain, domains.peertube[0], group_names)
| add_redirect_if_group('wordpress', "wordpress." ~ primary_domain, domains.wordpress[0], group_names)
}}
# Domains which are deprecated and should be cleaned up
deprecated_domains: []

View File

@ -0,0 +1,6 @@
defaults_domains: "{{ defaults_applications | canonical_domains_map(primary_domain) }}"
defaults_redirect_domain_mappings: "{{ applications | domain_mappings(primary_domain) }}"
# Domains which are deprecated and should be cleaned up
deprecated_domains: []

View File

@ -1,3 +1,2 @@
colorscheme-generator @ https://github.com/kevinveenbirkenbach/colorscheme-generator/archive/refs/tags/v0.3.0.zip
simpleaudio
numpy

View File

@ -5,3 +5,5 @@ pacman:
- ansible
- python-passlib
- python-pytest
yay:
- python-simpleaudio

View File

@ -10,3 +10,7 @@ features:
credentials:
# database_password: Needs to be defined in inventory file
# setup_admin_password: Needs to be defined in inventory file
domains:
canonical:
- "accounting.{{ primary_domain }}"

View File

@ -1,9 +1,11 @@
version: "latest"
credentials:
# database_password: Password for the database
features:
matomo: true
css: true
portfolio_iframe: false
central_database: true
domains:
canonical:
- "tickets.{{ primary_domain }}"

View File

@ -1,17 +1,10 @@
enable_greenlight: "true"
setup: false # Set to true in inventory file for initial setup
setup: false
credentials:
# shared_secret: # Needs to be defined in inventory file
# etherpad_api_key: # Needs to be defined in inventory file
# rails_secret: # Needs to be defined in inventory file
# postgresql_secret: # Needs to be defined in inventory file
# fsesl_password: # Needs to be defined in inventory file
# turn_secret: # Needs to be defined in inventory file
database:
name: "multiple_databases"
username: "postgres2"
urls:
api: "{{ web_protocol }}://{{domains | get_domain('bigbluebutton')}}/bigbluebutton/" # API Address used by Nextcloud Integration
api_suffix: "/bigbluebutton/"
features:
matomo: true
css: true
@ -19,3 +12,6 @@ features:
ldap: false
oidc: true
central_database: false
domains:
canonical:
- "meet.{{ primary_domain }}"

View File

@ -4,11 +4,12 @@ users:
pds:
version: "latest"
credentials:
#jwt_secret: # Needs to be defined in inventory file - Use: openssl rand -base64 64 | tr -d '\n'
#plc_rotation_key_k256_private_key_hex: # Needs to be defined in inventory file - Use: openssl rand -hex 32
#admin_password: # Needs to be defined in inventory file - Use: openssl rand -base64 16
features:
matomo: true
css: true
portfolio_iframe: true
central_database: true
domains:
canonical:
web: "bskyweb.{{ primary_domain }}"
api: "bluesky.{{ primary_domain }}"

View File

@ -17,3 +17,6 @@ csp:
whitelist:
font-src:
- "http://*.{{primary_domain}}"
domains:
canonical:
- "forum.{{ primary_domain }}"

View File

@ -1,3 +0,0 @@
# Jinja2 configuration template
# Define your variables here

View File

@ -0,0 +1 @@

View File

@ -17,3 +17,6 @@ csp:
script-src:
unsafe-inline: true
unsafe-eval: true
domains:
aliases:
- "crm.{{ primary_domain }}"

View File

@ -5,3 +5,6 @@ features:
portfolio_iframe: true
oidc: true
central_database: true
domains:
aliases:
- "social.{{ primary_domain }}"

View File

@ -6,5 +6,9 @@ features:
ldap: true
central_database: true
credentials:
# database_password: # Needs to be defined in inventory file
# django_secret: # Needs to be defined in inventory file
domains:
canonical:
- "audio.{{ primary_domain }}"
aliases:
- "music.{{ primary_domain }}"
- "sound.{{ primary_domain }}"

View File

@ -23,3 +23,6 @@ csp:
- "blob:"
manifest-src:
- "data:"
domains:
aliases:
- "git.{{ primary_domain }}"

View File

@ -1,3 +0,0 @@
# Jinja2 configuration template
# Define your variables here

View File

@ -3,3 +3,6 @@ features:
matomo: true
css: true
portfolio_iframe: true
domains:
canonical:
- "cms.{{ primary_domain }}"

View File

@ -17,3 +17,6 @@ csp:
unsafe-inline: true
style-src:
unsafe-inline: true
domains:
canonical:
- "auth.{{ primary_domain }}"

View File

@ -1,10 +1,8 @@
version: "latest"
oauth2_proxy:
application: application # Needs to be the same as webinterface
port: 80 # application port
application: application
port: 80
credentials:
# oauth2_proxy_cookie_secret: None # Set via openssl rand -hex 16
# administrator_password: "None" # CHANGE for security reasons
features:
matomo: true
css: true
@ -19,3 +17,7 @@ csp:
script-src:
unsafe-inline: true
unsafe-eval: true
domains:
aliases:
- "ldap.{{primary_domain}}"

View File

@ -9,3 +9,6 @@ features:
portfolio_iframe: true
central_database: true
oidc: true
domains:
canonical:
- "newsletter.{{ primary_domain }}"

View File

@ -7,14 +7,12 @@ oidc:
enable_user_creation: true # Users will be created if not existing
domain: "{{primary_domain}}" # The main domain from which mails will be send \ email suffix behind @
credentials:
# secret_key: # Set to a randomly generated 16 bytes string
# database_password: # Needs to be set in inventory file
# api_token: # Configures the authentication token. The minimum length is 3 characters. This is a mandatory setting for using the RESTful API.
# initial_administrator_password: # Initial administrator password for setup
# dkim_public_key: # Must be set in inventory file
features:
matomo: true
css: true
portfolio_iframe: false # Deactivated mailu iframe loading until keycloak supports it
oidc: true
central_database: false # Deactivate central database for mailu, I don't know why the database deactivation is necessary
domains:
canonical:
- "mail.{{ primary_domain }}"

View File

@ -2,18 +2,12 @@ version: "latest"
single_user_mode: false # Set true for initial setup
setup: false # Set true in inventory file to execute the setup and initializing procedures
credentials:
# Check out the README.md of the docker-mastodon role to get detailled instructions about how to setup the credentials
# database_password:
# secret_key_base:
# otp_secret:
# vapid_private_key:
# vapid_public_key:
# active_record_encryption_deterministic_key:
# active_record_encryption_key_derivation_salt:
# active_record_encryption_primary_key:
features:
matomo: true
css: true
portfolio_iframe: false
oidc: true
central_database: true
domains:
canonical:
- "microblog.{{ primary_domain }}"

View File

@ -17,3 +17,6 @@ csp:
unsafe-eval: true
style-src:
unsafe-inline: true
domains:
aliases:
- "analytics.{{ primary_domain }}"

View File

@ -25,9 +25,9 @@ csp:
whitelist:
connect-src:
- "{{ primary_domain }}"
- "{{ domains.matrix.synapse | safe_var }}"
- "matrix.{{ primary_domain }}"
script-src:
- "{{ domains.matrix.synapse | safe_var }}"
- "element.{{ primary_domain }}"
- "https://cdn.jsdelivr.net"
plugins:
# You need to enable them in the inventory file
@ -39,3 +39,8 @@ plugins:
slack: false
telegram: false
whatsapp: false
domains:
canonical:
synapse: "matrix.{{ primary_domain }}"
element: "element.{{ primary_domain }}"

View File

@ -0,0 +1,3 @@
domains:
canonical:
- "wiki.{{ primary_domain }}"

View File

@ -23,3 +23,6 @@ csp:
- "blob:"
script-src:
- "https://cdn.jsdelivr.net"
domains:
canonical:
- "academy.{{ primary_domain }}"

View File

@ -1,6 +1,4 @@
version: "production" # @see https://nextcloud.com/blog/nextcloud-release-channels-and-how-to-track-them/
ldap:
enabled: True # Enables LDAP by default
csp:
flags:
style-src:
@ -10,6 +8,10 @@ csp:
whitelist:
font-src:
- "data:"
domains:
canonical:
- "cloud.{{ primary_domain }}"
oidc:
enabled: "{{ applications.nextcloud.features.oidc | default(true) }}" # Activate OIDC for Nextcloud
# floavor decides which OICD plugin should be used.

View File

@ -4,4 +4,4 @@ plugin_configuration:
configvalue: "{{ applications.bigbluebutton.credentials.shared_secret }}"
- appid: "bbb"
configkey: "api.url"
configvalue: "{{ applications.bigbluebutton.urls.api }}"
configvalue: "{{ web_protocol }}://{{domains | get_domain(''bigbluebutton'')}}{{applications.bigbluebutton.api_suffix}}"

View File

@ -1,6 +1,5 @@
configuration_file: "oauth2-proxy-keycloak.cfg" # Needs to be set true in the roles which use it
version: "latest" # Docker Image version
redirect_url: "{{ web_protocol }}://{{domains | get_domain('keycloak')}}/auth/realms/{{primary_domain}}/protocol/openid-connect/auth" # The redirect URL for the OAuth2 flow. It should match the redirect URL configured in Keycloak.
allowed_roles: admin # Restrict it default to admin role. Use the vars/main.yml to open the specific role for other groups
features:
matomo: true

View File

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

View File

@ -10,3 +10,8 @@ csp:
unsafe-inline: true
style-src:
unsafe-inline: true
domains:
canonical:
- "video.{{ primary_domain }}"
aliases:
- "videos.{{ primary_domain }}"

View File

@ -16,3 +16,7 @@ csp:
unsafe-inline: true
script-src:
unsafe-inline: true
domains:
aliases:
- "mysql.{{ primary_domain }}"
- "mariadb.{{ primary_domain }}"

View File

@ -12,3 +12,8 @@ csp:
unsafe-eval: true
style-src:
unsafe-inline: true
domains:
canonical:
- "picture.{{ primary_domain }}"
aliases:
- "pictures.{{ primary_domain }}"

View File

@ -19,3 +19,7 @@ csp:
flags:
style-src:
unsafe-inline: true
domains:
canonical:
- "{{ primary_domain }}"

View File

@ -19,3 +19,6 @@ csp:
unsafe-inline: true
script-src:
unsafe-eval: true
domains:
canonical:
- "slides.{{ primary_domain }}"

View File

@ -0,0 +1,3 @@
domains:
canonical:
- "wheel.{{ primary_domain }}"

View File

@ -4,3 +4,6 @@ features:
css: true
portfolio_iframe: false
central_database: true
domains:
canonical:
- "inventory.{{ primary_domain }}"

View File

@ -9,3 +9,6 @@ csp:
unsafe-eval: true
style-src:
unsafe-inline: true
domains:
canonical:
- "docs.{{ primary_domain }}"

View File

@ -20,3 +20,6 @@ csp:
unsafe-eval: true
style-src:
unsafe-inline: true
domains:
canonical:
- "kanban.{{ primary_domain }}"

View File

@ -31,6 +31,9 @@ csp:
- "https://fonts.bunny.net"
script-src:
- "https://cdn.gtranslate.net"
- "{{ domains | get_domain('wordpress') }}"
- "blog.{{ primary_domain }}"
style-src:
- "https://fonts.bunny.net"
domains:
canonical:
- "blog.{{ primary_domain }}"

View File

View File

@ -12,3 +12,8 @@ features:
portfolio_iframe: false
central_database: true
oauth2: true
domains:
canonical:
- "s.{{ primary_domain }}"
aliases:
- "short.{{ primary_domain }}"

View File

@ -2,3 +2,6 @@ features:
matomo: true
css: true
portfolio_iframe: true
domains:
canonical:
- "files.{{ primary_domain }}"

View File

@ -2,3 +2,6 @@ features:
matomo: true
css: true
portfolio_iframe: false
domains:
canonical:
- "html.{{ primary_domain }}"

View File

@ -8,6 +8,11 @@
- name: Merge system_email definitions
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 domain definitions
set_fact:
domains: "{{ defaults_domains | combine(domains | default({}, true), recursive=True) }}"
@ -28,10 +33,6 @@
redirect_domain_mappings: "{{ redirect_domain_mappings | default([]) + [ {'source': item.key, 'target': item.value} ] }}"
loop: "{{ combined_mapping | dict2items }}"
- name: Merge application definitions
set_fact:
applications: "{{ defaults_applications | combine(applications | default({}, true), recursive=True) }}"
# @todo implement
# - name: Ensure features.integrated is set based on group membership
# set_fact:

View File

@ -0,0 +1,55 @@
import unittest
import yaml
import subprocess
from pathlib import Path
from collections import Counter
class TestDomainUniqueness(unittest.TestCase):
def test_no_duplicate_domains(self):
"""
Load the applications YAML (generating it via `make build` if missing),
collect all entries under domains.canonical and domains.aliases across all applications,
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'
# Generate the file if it doesn't exist
if not yaml_file.exists():
subprocess.run(['make', 'build'], cwd=repo_root, check=True)
# Load the applications configuration
cfg = yaml.safe_load(yaml_file.read_text(encoding='utf-8')) or {}
apps = cfg.get('defaults_applications', {})
all_domains = []
for app_name, app_cfg in apps.items():
domains_cfg = app_cfg.get('domains', {})
# canonical entries may be a list or a mapping
canonical = domains_cfg.get('canonical', [])
if isinstance(canonical, dict):
values = list(canonical.values())
else:
values = canonical or []
all_domains.extend(values)
# aliases entries may be a list or a mapping
aliases = domains_cfg.get('aliases', [])
if isinstance(aliases, dict):
values = list(aliases.values())
else:
values = aliases or []
all_domains.extend(values)
# Filter out any empty or non-string entries
domain_list = [d for d in all_domains if isinstance(d, str) and d.strip()]
counts = Counter(domain_list)
# Find duplicates
duplicates = [domain for domain, count in counts.items() if count > 1]
if duplicates:
self.fail(f"Duplicate domain entries found: {duplicates}\n (May 'make build' solves this issue.)")
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,89 @@
import os
import yaml
import unittest
from pathlib import Path
from collections import Counter
ROLES_DIR = Path(__file__).resolve().parent.parent.parent / "roles"
class TestDomainsStructure(unittest.TestCase):
def test_domains_keys_types_and_uniqueness(self):
"""Ensure that under 'domains' only 'canonical' and 'aliases' keys exist,
'aliases' is a list of strings, 'canonical' is either a list of strings
or a dict with string values, and no domain is defined more than once
across all roles."""
failed_roles = []
all_domains = []
for role_path in ROLES_DIR.iterdir():
if not role_path.is_dir():
continue
vars_dir = role_path / "vars"
if not vars_dir.exists():
continue
for vars_file in vars_dir.glob("*.yml"):
try:
with open(vars_file, 'r') as f:
data = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
failed_roles.append((role_path.name, vars_file.name, f"YAML error: {e}"))
continue
if 'domains' not in data:
continue
domains = data['domains']
if not isinstance(domains, dict):
failed_roles.append((role_path.name, vars_file.name, "'domains' should be a dict"))
continue
# Check allowed keys
allowed_keys = {'canonical', 'aliases'}
extra_keys = set(domains.keys()) - allowed_keys
if extra_keys:
failed_roles.append((role_path.name, vars_file.name,
f"Unexpected keys in 'domains': {extra_keys}"))
# Validate and collect 'aliases'
if 'aliases' in domains:
aliases = domains['aliases']
if not isinstance(aliases, list) or not all(isinstance(item, str) for item in aliases):
failed_roles.append((role_path.name, vars_file.name,
"'aliases' must be a list of strings"))
else:
all_domains.extend(aliases)
# Validate and collect 'canonical'
if 'canonical' in domains:
canonical = domains['canonical']
if isinstance(canonical, list):
if not all(isinstance(item, str) for item in canonical):
failed_roles.append((role_path.name, vars_file.name,
"'canonical' list items must be strings"))
else:
all_domains.extend(canonical)
elif isinstance(canonical, dict):
if not all(isinstance(k, str) and isinstance(v, str) for k, v in canonical.items()):
failed_roles.append((role_path.name, vars_file.name,
"All keys and values in 'canonical' dict must be strings"))
else:
all_domains.extend(canonical.values())
else:
failed_roles.append((role_path.name, vars_file.name,
"'canonical' must be a list or a dict"))
# Check for duplicate domains across all roles
duplicates = [domain for domain, count in Counter(all_domains).items() if count > 1]
if duplicates:
failed_roles.append(("GLOBAL", "", f"Duplicate domain entries found: {duplicates}"))
if failed_roles:
messages = []
for role, file, reason in failed_roles:
entry = f"{role}/{file}: {reason}" if file else f"{role}: {reason}"
messages.append(entry)
self.fail("Domain structure errors found:\n" + "\n".join(messages))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,103 @@
import os
import sys
import unittest
# Add the filter_plugins directory to the import path
dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins')
)
sys.path.insert(0, dir_path)
from ansible.errors import AnsibleFilterError
from alias_domains_map import FilterModule
class TestDomainFilters(unittest.TestCase):
def setUp(self):
self.filter_module = FilterModule()
# Sample primary domain
self.primary = 'example.com'
def test_alias_empty_apps(self):
apps = {}
expected = {}
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_alias_without_aliases_and_no_canonical(self):
apps = {'app1': {}}
# canonical defaults to ['app1.example.com'], so alias should be []
expected = {'app1': []}
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_alias_with_explicit_aliases(self):
apps = {
'app1': {
'domains': {'aliases': ['alias.com']}
}
}
# canonical defaults to ['app1.example.com'], so alias should include alias.com and default
expected = {'app1': ['alias.com', 'app1.example.com']}
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertCountEqual(result['app1'], expected['app1'])
def test_alias_with_canonical_not_default(self):
apps = {
'app1': {
'domains': {'canonical': ['foo.com']}
}
}
# foo.com is canonical, default not in canonical so added as alias
expected = {'app1': ['app1.example.com']}
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_alias_with_existing_default(self):
apps = {
'app1': {
'domains': {
'canonical': ['foo.com'],
'aliases': ['app1.example.com']
}
}
}
# default present in aliases, should not be duplicated
expected = {'app1': ['app1.example.com']}
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_invalid_aliases_type(self):
apps = {
'app1': {'domains': {'aliases': 123}}
}
with self.assertRaises(AnsibleFilterError):
self.filter_module.alias_domains_map(apps, self.primary)
def test_alias_with_empty_domains_cfg(self):
apps = {
'app1': {
'domains': {}
}
}
expected = apps
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_alias_with_canonical_dict_not_default(self):
apps = {
'app1': {
'domains': {
'canonical': {
'one': 'one.com',
'two': 'two.com'
}
}
}
}
expected = {'app1': ['app1.example.com']}
result = self.filter_module.alias_domains_map(apps, self.primary)
self.assertEqual(result, expected)
if __name__ == "__main__":
unittest.main()

View File

@ -8,7 +8,7 @@ sys.path.insert(
os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins'))
)
from domain_filters import FilterModule
from generate_all_domains import FilterModule
class TestGenerateAllDomains(unittest.TestCase):
def setUp(self):

View File

@ -8,7 +8,7 @@ sys.path.insert(
os.path.abspath(os.path.join(os.path.dirname(__file__), '../../filter_plugins'))
)
from domain_filters import FilterModule
from generate_base_sld_domains import FilterModule
class TestGenerateBaseSldDomains(unittest.TestCase):
def setUp(self):

View File

@ -0,0 +1,74 @@
import os
import sys
import unittest
# Add the filter_plugins directory to the import path
dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins')
)
sys.path.insert(0, dir_path)
from ansible.errors import AnsibleFilterError
from canonical_domains_map import FilterModule
class TestDomainFilters(unittest.TestCase):
def setUp(self):
self.filter_module = FilterModule()
# Sample primary domain
self.primary = 'example.com'
def test_canonical_empty_apps(self):
apps = {}
expected = {}
result = self.filter_module.canonical_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_canonical_without_domains(self):
apps = {'app1': {}}
expected = {'app1': ['app1.example.com']}
result = self.filter_module.canonical_domains_map(apps, self.primary)
self.assertEqual(result, expected)
def test_canonical_with_list(self):
apps = {
'app1': {
'domains': {'canonical': ['foo.com', 'bar.com']}
}
}
result = self.filter_module.canonical_domains_map(apps, self.primary)
self.assertCountEqual(
result['app1'],
['foo.com', 'bar.com']
)
def test_canonical_with_dict(self):
apps = {
'app1': {
'domains': {'canonical': {'one': 'one.com', 'two': 'two.com'}}
}
}
result = self.filter_module.canonical_domains_map(apps, self.primary)
self.assertEqual(
result['app1'],
{'one': 'one.com', 'two': 'two.com'}
)
def test_canonical_duplicate_raises(self):
apps = {
'app1': {'domains': {'canonical': ['dup.com']}},
'app2': {'domains': {'canonical': ['dup.com']}},
}
with self.assertRaises(AnsibleFilterError) as cm:
self.filter_module.canonical_domains_map(apps, self.primary)
# Updated to match new exception message
self.assertIn("already configured for", str(cm.exception))
def test_invalid_canonical_type(self):
apps = {
'app1': {'domains': {'canonical': 123}}
}
with self.assertRaises(AnsibleFilterError):
self.filter_module.canonical_domains_map(apps, self.primary)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,105 @@
import os
import sys
import unittest
# Add the filter_plugins directory to the import path
dir_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), '../../filter_plugins')
)
sys.path.insert(0, dir_path)
from ansible.errors import AnsibleFilterError
from domain_redirect_mappings import FilterModule
class TestDomainMappings(unittest.TestCase):
def setUp(self):
self.filter = FilterModule()
self.primary = 'example.com'
def test_empty_apps(self):
apps = {}
result = self.filter.domain_mappings(apps, self.primary)
self.assertEqual(result, [])
def test_app_without_domains(self):
apps = {'app1': {}}
# no domains key → no mappings
result = self.filter.domain_mappings(apps, self.primary)
self.assertEqual(result, [])
def test_empty_domains_cfg(self):
apps = {'app1': {'domains': {}}}
default = 'app1.example.com'
expected = [
{'source': default, 'target': default}
]
result = self.filter.domain_mappings(apps, self.primary)
self.assertEqual(result, expected)
def test_explicit_aliases(self):
apps = {
'app1': {
'domains': {'aliases': ['alias.com']}
}
}
default = 'app1.example.com'
expected = [
{'source': 'alias.com', 'target': default},
{'source': default, 'target': default},
]
result = self.filter.domain_mappings(apps, self.primary)
# order not important
self.assertCountEqual(result, expected)
def test_canonical_not_default(self):
apps = {
'app1': {
'domains': {'canonical': ['foo.com']}
}
}
expected = [
{'source': 'app1.example.com', 'target': 'foo.com'}
]
result = self.filter.domain_mappings(apps, self.primary)
self.assertEqual(result, expected)
def test_canonical_dict(self):
apps = {
'app1': {
'domains': {
'canonical': {'one': 'one.com', 'two': 'two.com'}
}
}
}
# first canonical key 'one' → one.com
expected = [
{'source': 'app1.example.com', 'target': 'one.com'}
]
result = self.filter.domain_mappings(apps, self.primary)
self.assertEqual(result, expected)
def test_multiple_apps(self):
apps = {
'app1': {'domains': {'aliases': ['a1.com']}},
'app2': {'domains': {'canonical': ['c2.com']}},
}
expected = [
# app1
{'source': 'a1.com', 'target': 'app1.example.com'},
{'source': 'app1.example.com', 'target': 'app1.example.com'},
# app2
{'source': 'app2.example.com', 'target': 'c2.com'},
]
result = self.filter.domain_mappings(apps, self.primary)
self.assertCountEqual(result, expected)
def test_invalid_aliases_type(self):
apps = {
'app1': {'domains': {'aliases': 123}}
}
with self.assertRaises(AnsibleFilterError):
self.filter.domain_mappings(apps, self.primary)
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" / "11_applications.yml"
self.output_file = self.temp_dir / "group_vars" / "all" / "03_applications.yml"
def tearDown(self):
shutil.rmtree(self.temp_dir)