In between commit domain restruturing

This commit is contained in:
Kevin Veen-Birkenbach 2025-05-19 17:17:57 +02:00
parent cc3f5d75ea
commit 37dcc5f74e
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
63 changed files with 771 additions and 242 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
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

@ -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))
@ -31,4 +31,4 @@ class FilterModule(object):
return {
'is_feature_enabled': is_feature_enabled,
'get_docker_compose': get_docker_compose,
}
}

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))
@ -99,7 +100,7 @@ class FilterModule(object):
for directive in directives:
tokens = ["'self'"]
# unsafe-eval / unsafe-inline flags
flags = self.get_csp_flags(applications, application_id, directive)
tokens += flags

View File

@ -3,16 +3,101 @@ 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
Ansible Filter Plugin for Domain Processing
This plugin provides filters to manage and transform domain configurations for applications:
- generate_all_domains(domains_dict, include_www=True):
Flattens nested domain values (string, list, or dict), optionally adds 'www.' prefixes,
removes duplicates, and returns a sorted list of unique domains.
- generate_base_sld_domains(domains_dict, redirect_mappings):
Flattens domains and redirect mappings, extracts second-level + top-level domains (SLDs),
deduplicates, and returns a sorted list of base domains.
- canonical_domains_map(apps, primary_domain):
Builds a mapping of application IDs to their canonical domains using
DomainUtils.canonical_list, enforcing uniqueness and detecting conflicts.
- alias_domains_map(apps, primary_domain):
Generates alias domains for each application via DomainUtils.alias_list,
based on their canonical domains and provided configurations.
"""
def filters(self):
return {
'generate_all_domains': self.generate_all_domains,
'generate_base_sld_domains': self.generate_base_sld_domains,
'generate_all_domains': self.generate_all_domains,
'generate_base_sld_domains': self.generate_base_sld_domains,
'canonical_domains_map': self.canonical_domains_map,
'alias_domains_map': self.alias_domains_map,
}
@staticmethod
def parse_entry(domains_cfg, key, app_id):
"""
Extract list of strings from domains_cfg[key], which may be dict or list.
Returns None if key not in domains_cfg.
Raises AnsibleFilterError on invalid type or empty/invalid values.
"""
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
@staticmethod
def default_domain(app_id, primary_domain):
"""
Returns the default domain string for an application.
"""
return f"{app_id}.{primary_domain}"
@classmethod
def canonical_list(cls, domains_cfg, app_id, primary_domain):
"""
Returns the list of canonical domains: parsed entry or default.
"""
domains = cls.parse_entry(domains_cfg, 'canonical', app_id)
if domains is None:
return [cls.default_domain(app_id, primary_domain)]
return domains
@classmethod
def alias_list(cls, domains_cfg, app_id, primary_domain, canonical_domains=None):
"""
Returns the list of alias domains based on:
- explicit aliases entry
- presence of canonical entry and default not in canonical
Always ensures default domain in aliases when appropriate.
"""
default = cls.default_domain(app_id, primary_domain)
aliases = cls.parse_entry(domains_cfg, 'aliases', app_id) or []
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:
# use provided canonical_domains if given otherwise parse
canon = canonical_domains if canonical_domains is not None else cls.parse_entry(domains_cfg, 'canonical', app_id)
if default not in (canon or []):
aliases.append(default)
# else: neither defined -> empty list
return aliases
@staticmethod
def generate_all_domains(domains_dict, include_www=True):
@ -67,3 +152,33 @@ class FilterModule(object):
elif isinstance(val, dict):
flat.extend(val.values())
return flat
def canonical_domains_map(self, apps, primary_domain):
result = {}
seen = {}
for app_id, app_cfg in apps.items():
domains_cfg = app_cfg.get('domains', {}) or {}
domains = self.canonical_list(domains_cfg, app_id, primary_domain)
for d in domains:
if d in seen:
raise AnsibleFilterError(
f"Domain '{d}' is configured for both '{seen[d]}' and '{app_id}'"
)
seen[d] = app_id
result[app_id] = domains
return result
def alias_domains_map(self, apps, primary_domain):
result = {}
# wir können die canonical_map vorab holen…
canonical_map = self.canonical_domains_map(apps, primary_domain)
for app_id, app_cfg in apps.items():
domains_cfg = app_cfg.get('domains', {}) or {}
aliases = self.alias_list(
domains_cfg,
app_id,
primary_domain,
canonical_domains=canonical_map.get(app_id),
)
result[app_id] = aliases
return result

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
plugin_dir = os.path.dirname(__file__)
project_root = os.path.abspath(os.path.join(plugin_dir, '..'))
# 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
@ -83,4 +94,4 @@ class FilterModule(object):
return result
except Exception as exc:
raise AnsibleFilterError(f"add_domain_if_group failed: {exc}")
raise AnsibleFilterError(f"add_domain_if_group failed: {exc}")

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,10 @@
defaults_domains: "{{ defaults_applications | canonical_domains_map(primary_domain) }}"
defaults_redirect_domain_mappings: >-
{{ []
| add_redirect_if_group('lam', domains.ldap, domains.lam, group_names)
| add_redirect_if_group('phpmyldapadmin', domains.ldap, domains.phpmyldapadmin,group_names)
}}
# 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

@ -4,4 +4,6 @@ collections:
pacman:
- ansible
- python-passlib
- python-pytest
- python-pytest
yay:
- python-simpleaudio

View File

@ -9,4 +9,8 @@ features:
central_database: true
credentials:
# database_password: Needs to be defined in inventory file
# setup_admin_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

@ -7,3 +7,7 @@ features:
css: true
portfolio_iframe: false
central_database: true
domains:
canonical:
- "tickets.{{ primary_domain }}"

View File

@ -2,5 +2,5 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
central_database: true

View File

@ -1,21 +1,17 @@
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
name: "multiple_databases"
username: "postgres2"
api_suffix: "/bigbluebutton/"
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: false
ldap: false
oidc: true
central_database: false
central_database: false
domains:
canonical:
- "meet.{{ primary_domain }}"

View File

@ -1,14 +1,15 @@
users:
administrator:
email: "{{users.administrator.email}}"
email: "{{users.administrator.email}}"
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
version: "latest"
credentials:
features:
matomo: true
css: true
matomo: true
css: true
portfolio_iframe: true
central_database: true
central_database: true
domains:
canonical:
web: "bskyweb.{{ primary_domain }}"
api: "bluesky.{{ primary_domain }}"

View File

@ -16,4 +16,7 @@ csp:
unsafe-inline: true
whitelist:
font-src:
- "http://*.{{primary_domain}}"
- "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

@ -1,19 +1,22 @@
version: "latest"
version: "latest"
users:
administrator:
username: "{{ users.administrator.username }}"
email: "{{ users.administrator.email }}"
username: "{{ users.administrator.username }}"
email: "{{ users.administrator.email }}"
credentials:
features:
matomo: true
css: false
portfolio_iframe: false
portfolio_iframe: false
ldap: false
oidc: true
central_database: true
csp:
flags:
script-src:
unsafe-inline: true
unsafe-eval: true
unsafe-inline: true
unsafe-eval: true
domains:
aliases:
- "crm.{{ primary_domain }}"

View File

@ -2,6 +2,9 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
oidc: true
central_database: 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

@ -22,4 +22,7 @@ csp:
worker-src:
- "blob:"
manifest-src:
- "data:"
- "data:"
domains:
aliases:
- "git.{{ primary_domain }}"

View File

@ -2,5 +2,5 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
central_database: true

View File

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

View File

@ -2,4 +2,7 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
domains:
canonical:
- "cms.{{ primary_domain }}"

View File

@ -16,4 +16,7 @@ csp:
script-src:
unsafe-inline: true
style-src:
unsafe-inline: true
unsafe-inline: true
domains:
canonical:
- "auth.{{ primary_domain }}"

View File

@ -1,21 +1,23 @@
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
portfolio_iframe: true
portfolio_iframe: true
ldap: true
central_database: false
oauth2: false
csp:
flags:
style-src:
unsafe-inline: true
unsafe-inline: true
script-src:
unsafe-inline: true
unsafe-eval: true
unsafe-inline: true
unsafe-eval: true
domains:
aliases:
- "ldap.{{primary_domain}}"

View File

@ -1,13 +1,13 @@
version: "latest"
version: "latest"
network:
local: True # Activates local network. Necessary for LDIF import routines
docker: True # Activates docker network to allow other docker containers to connect
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
local: True # Activates local network. Necessary for LDIF import routines
docker: True # Activates docker network to allow other docker containers to connect
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
username: "{{users.administrator.username}}" # Administrator username
credentials:
features:
ldap: true
ldap: true

View File

@ -6,6 +6,9 @@ version: "latest" # Docker Image
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
central_database: true
oidc: 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
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
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

@ -1,14 +1,14 @@
application_id: "mailu"
application_id: "mailu"
# Database Configuration
database_password: "{{applications.mailu.credentials.database_password}}"
database_type: "mariadb"
database_password: "{{applications.mailu.credentials.database_password}}"
database_type: "mariadb"
cert_mount_directory: "{{docker_compose.directories.volumes}}certs/"
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[application_id].features.oidc | bool else 'ghcr.io/mailu' }}"
domain: "{{ domains | get_domain(application_id) }}"
http_port: "{{ ports.localhost.http[application_id] }}"
domain: "{{ domains | get_domain(application_id) }}"
http_port: "{{ ports.localhost.http[application_id] }}"

View File

@ -1,19 +1,13 @@
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:
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:
features:
matomo: true
css: true
portfolio_iframe: false
oidc: true
central_database: true
matomo: true
css: true
portfolio_iframe: false
oidc: true
central_database: true
domains:
canonical:
- "microblog.{{ primary_domain }}"

View File

@ -2,7 +2,7 @@ version: "latest"
features:
matomo: true
css: false
portfolio_iframe: false
portfolio_iframe: false
central_database: true
oauth2: false
csp:
@ -16,4 +16,7 @@ csp:
unsafe-inline: true
unsafe-eval: true
style-src:
unsafe-inline: true
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

@ -22,4 +22,7 @@ csp:
- "data:"
- "blob:"
script-src:
- "https://cdn.jsdelivr.net"
- "https://cdn.jsdelivr.net"
domains:
canonical:
- "academy.{{ primary_domain }}"

View File

@ -3,5 +3,5 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: false
central_database: true
portfolio_iframe: false
central_database: true

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.
@ -23,7 +25,7 @@ credentials:
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: false
ldap: true
oidc: true
central_database: true

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,7 +1,6 @@
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
configuration_file: "oauth2-proxy-keycloak.cfg" # Needs to be set true in the roles which use it
version: "latest" # Docker Image version
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
css: true

View File

@ -16,4 +16,7 @@ features:
csp:
flags:
script-src:
unsafe-inline: true
unsafe-inline: true
domains:
canonical:
- "project.{{ primary_domain }}"

View File

@ -9,4 +9,9 @@ csp:
script-src:
unsafe-inline: true
style-src:
unsafe-inline: true
unsafe-inline: true
domains:
canonical:
- "video.{{ primary_domain }}"
aliases:
- "videos.{{ primary_domain }}"

View File

@ -5,6 +5,6 @@ oauth2_proxy:
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: false
ldap: true
oauth2: true

View File

@ -6,7 +6,7 @@ oauth2_proxy:
features:
matomo: true
css: false
portfolio_iframe: false
portfolio_iframe: false
central_database: true
oauth2: true
hostname: central-mariadb
@ -15,4 +15,8 @@ csp:
style-src:
unsafe-inline: true
script-src:
unsafe-inline: true
unsafe-inline: true
domains:
aliases:
- "mysql.{{ primary_domain }}"
- "mariadb.{{ primary_domain }}"

View File

@ -3,7 +3,7 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: false
central_database: true
csp:
flags:
@ -11,4 +11,9 @@ csp:
unsafe-inline: true
unsafe-eval: true
style-src:
unsafe-inline: true
unsafe-inline: true
domains:
canonical:
- "picture.{{ primary_domain }}"
aliases:
- "pictures.{{ primary_domain }}"

View File

@ -1,6 +1,6 @@
features:
matomo: true
css: true
matomo: true
css: true
portfolio_iframe: false
csp:
whitelist:
@ -19,3 +19,7 @@ csp:
flags:
style-src:
unsafe-inline: true
domains:
canonical:
- "{{ primary_domain }}"

View File

@ -1,7 +1,7 @@
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
csp:
whitelist:
@ -18,4 +18,7 @@ csp:
style-src:
unsafe-inline: true
script-src:
unsafe-eval: true
unsafe-eval: true
domains:
canonical:
- "slides.{{ primary_domain }}"

View File

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

View File

@ -2,5 +2,8 @@ version: "latest"
features:
matomo: true
css: true
portfolio_iframe: false
central_database: true
portfolio_iframe: false
central_database: true
domains:
canonical:
- "inventory.{{ primary_domain }}"

View File

@ -1,6 +1,6 @@
features:
matomo: true
css: true
matomo: true
css: true
portfolio_iframe: false
csp:
flags:
@ -8,4 +8,7 @@ csp:
unsafe-inline: true
unsafe-eval: true
style-src:
unsafe-inline: true
unsafe-inline: true
domains:
canonical:
- "docs.{{ primary_domain }}"

View File

@ -19,4 +19,7 @@ csp:
unsafe-inline: true
unsafe-eval: true
style-src:
unsafe-inline: true
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"
- "https://fonts.bunny.net"
domains:
canonical:
- "blog.{{ primary_domain }}"

View File

View File

@ -9,6 +9,11 @@ oauth2_proxy:
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: false
central_database: true
oauth2: true
oauth2: true
domains:
canonical:
- "s.{{ primary_domain }}"
aliases:
- "short.{{ primary_domain }}"

View File

@ -1,4 +1,7 @@
features:
matomo: true
css: true
portfolio_iframe: true
portfolio_iframe: true
domains:
canonical:
- "files.{{ primary_domain }}"

View File

@ -1,4 +1,7 @@
features:
matomo: true
css: true
portfolio_iframe: false
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,123 @@
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_filters 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.assertCountEqual(result['app1'], ['one.com', '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)
self.assertIn("configured for both", str(cm.exception))
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_canonical_type(self):
apps = {
'app1': {'domains': {'canonical': 123}}
}
with self.assertRaises(AnsibleFilterError):
self.filter_module.canonical_domains_map(apps, self.primary)
def test_invalid_aliases_type(self):
apps = {
'app1': {'domains': {'aliases': 123}}
}
with self.assertRaises(AnsibleFilterError):
self.filter_module.alias_domains_map(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)