mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-06-25 11:45:32 +02:00
Compare commits
7 Commits
3c7825fd23
...
969a176be1
Author | SHA1 | Date | |
---|---|---|---|
969a176be1 | |||
5948d7aa93 | |||
a051fde662 | |||
23ccaca3aa | |||
a51a474cb3 | |||
d5dd568994 | |||
2f1d6a5178 |
2
Makefile
2
Makefile
@ -2,7 +2,7 @@ ROLES_DIR := ./roles
|
|||||||
APPLICATIONS_OUT := ./group_vars/all/03_applications.yml
|
APPLICATIONS_OUT := ./group_vars/all/03_applications.yml
|
||||||
APPLICATIONS_SCRIPT := ./cli/generate-applications-defaults.py
|
APPLICATIONS_SCRIPT := ./cli/generate-applications-defaults.py
|
||||||
INCLUDES_OUT := ./tasks/include-docker-roles.yml
|
INCLUDES_OUT := ./tasks/include-docker-roles.yml
|
||||||
INCLUDES_SCRIPT := ./cli/generate-role-includes.py
|
INCLUDES_SCRIPT := ./cli/generate_playbook.py
|
||||||
|
|
||||||
.PHONY: build install test
|
.PHONY: build install test
|
||||||
|
|
||||||
|
@ -1,79 +0,0 @@
|
|||||||
import os
|
|
||||||
import argparse
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
def find_roles(roles_dir, prefix=None):
|
|
||||||
"""
|
|
||||||
Yield absolute paths of role directories under roles_dir.
|
|
||||||
Only include roles whose directory name starts with prefix (if given) and contain vars/main.yml.
|
|
||||||
"""
|
|
||||||
for entry in os.listdir(roles_dir):
|
|
||||||
if prefix and not entry.startswith(prefix):
|
|
||||||
continue
|
|
||||||
path = os.path.join(roles_dir, entry)
|
|
||||||
vars_file = os.path.join(path, 'vars', 'main.yml')
|
|
||||||
if os.path.isdir(path) and os.path.isfile(vars_file):
|
|
||||||
yield path, vars_file
|
|
||||||
|
|
||||||
|
|
||||||
def load_application_id(vars_file):
|
|
||||||
"""
|
|
||||||
Load the vars/main.yml and return the value of application_id key.
|
|
||||||
Returns None if not found.
|
|
||||||
"""
|
|
||||||
with open(vars_file, 'r') as f:
|
|
||||||
data = yaml.safe_load(f) or {}
|
|
||||||
return data.get('application_id')
|
|
||||||
|
|
||||||
|
|
||||||
def generate_playbook_entries(roles_dir, prefix=None):
|
|
||||||
entries = []
|
|
||||||
for role_path, vars_file in find_roles(roles_dir, prefix):
|
|
||||||
app_id = load_application_id(vars_file)
|
|
||||||
if not app_id:
|
|
||||||
continue
|
|
||||||
# Derive role name from directory name
|
|
||||||
role_name = os.path.basename(role_path)
|
|
||||||
# entry text
|
|
||||||
entry = (
|
|
||||||
f"- name: setup {app_id}\n"
|
|
||||||
f" when: (\"{app_id}\" in group_names)\n"
|
|
||||||
f" include_role:\n"
|
|
||||||
f" name: {role_name}\n"
|
|
||||||
)
|
|
||||||
entries.append(entry)
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description='Generate an Ansible playbook include file from Docker roles and application_ids.'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'roles_dir',
|
|
||||||
help='Path to directory containing role folders'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'-p', '--prefix',
|
|
||||||
help='Only include roles whose names start with this prefix (e.g. docker-, client-)',
|
|
||||||
default=None
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'-o', '--output',
|
|
||||||
help='Output file path (default: stdout)',
|
|
||||||
default=None
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
entries = generate_playbook_entries(args.roles_dir, args.prefix)
|
|
||||||
output = ''.join(entries)
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
with open(args.output, 'w') as f:
|
|
||||||
f.write(output)
|
|
||||||
print(f"Playbook entries written to {args.output}")
|
|
||||||
else:
|
|
||||||
print(output)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
174
cli/generate_playbook.py
Normal file
174
cli/generate_playbook.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import argparse
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
def find_roles(roles_dir, prefix=None):
|
||||||
|
"""Find all roles in the given directory."""
|
||||||
|
for entry in os.listdir(roles_dir):
|
||||||
|
if prefix and not entry.startswith(prefix):
|
||||||
|
continue
|
||||||
|
path = os.path.join(roles_dir, entry)
|
||||||
|
meta_file = os.path.join(path, 'meta', 'main.yml')
|
||||||
|
if os.path.isdir(path) and os.path.isfile(meta_file):
|
||||||
|
yield path, meta_file
|
||||||
|
|
||||||
|
def load_run_after(meta_file):
|
||||||
|
"""Load the 'run_after' from the meta/main.yml of a role."""
|
||||||
|
with open(meta_file, 'r') as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
return data.get('galaxy_info', {}).get('run_after', [])
|
||||||
|
|
||||||
|
def load_application_id(role_path):
|
||||||
|
"""Load the application_id from the vars/main.yml of the role."""
|
||||||
|
vars_file = os.path.join(role_path, 'vars', 'main.yml')
|
||||||
|
if os.path.exists(vars_file):
|
||||||
|
with open(vars_file, 'r') as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
return data.get('application_id')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_dependency_graph(roles_dir, prefix=None):
|
||||||
|
"""Build a dependency graph where each role points to the roles it depends on."""
|
||||||
|
graph = defaultdict(list)
|
||||||
|
in_degree = defaultdict(int)
|
||||||
|
roles = {}
|
||||||
|
|
||||||
|
for role_path, meta_file in find_roles(roles_dir, prefix):
|
||||||
|
run_after = load_run_after(meta_file)
|
||||||
|
application_id = load_application_id(role_path)
|
||||||
|
role_name = os.path.basename(role_path)
|
||||||
|
roles[role_name] = {
|
||||||
|
'role_name': role_name,
|
||||||
|
'run_after': run_after,
|
||||||
|
'application_id': application_id,
|
||||||
|
'path': role_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the role has dependencies, build the graph
|
||||||
|
for dependency in run_after:
|
||||||
|
graph[dependency].append(role_name)
|
||||||
|
in_degree[role_name] += 1
|
||||||
|
|
||||||
|
# Ensure roles with no dependencies have an in-degree of 0
|
||||||
|
if role_name not in in_degree:
|
||||||
|
in_degree[role_name] = 0
|
||||||
|
|
||||||
|
return graph, in_degree, roles
|
||||||
|
|
||||||
|
def topological_sort(graph, in_degree):
|
||||||
|
"""Perform topological sort on the dependency graph."""
|
||||||
|
# Queue for roles with no incoming dependencies (in_degree == 0)
|
||||||
|
queue = deque([role for role, degree in in_degree.items() if degree == 0])
|
||||||
|
sorted_roles = []
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
role = queue.popleft()
|
||||||
|
sorted_roles.append(role)
|
||||||
|
|
||||||
|
# Reduce in-degree for roles dependent on the current role
|
||||||
|
for neighbor in graph[role]:
|
||||||
|
in_degree[neighbor] -= 1
|
||||||
|
if in_degree[neighbor] == 0:
|
||||||
|
queue.append(neighbor)
|
||||||
|
|
||||||
|
if len(sorted_roles) != len(in_degree):
|
||||||
|
# If the number of sorted roles doesn't match the number of roles,
|
||||||
|
# there was a cycle in the graph (not all roles could be sorted)
|
||||||
|
raise Exception("Circular dependency detected among the roles!")
|
||||||
|
|
||||||
|
return sorted_roles
|
||||||
|
|
||||||
|
def print_dependency_tree(graph):
|
||||||
|
"""Print the dependency tree visually on the console."""
|
||||||
|
def print_node(role, indent=0):
|
||||||
|
print(" " * indent + role)
|
||||||
|
for dependency in graph[role]:
|
||||||
|
print_node(dependency, indent + 1)
|
||||||
|
|
||||||
|
# Print the tree starting from roles with no dependencies
|
||||||
|
all_roles = set(graph.keys())
|
||||||
|
dependent_roles = {role for dependencies in graph.values() for role in dependencies}
|
||||||
|
root_roles = all_roles - dependent_roles
|
||||||
|
|
||||||
|
printed_roles = []
|
||||||
|
|
||||||
|
def collect_roles(role, indent=0):
|
||||||
|
printed_roles.append(role)
|
||||||
|
for dependency in graph[role]:
|
||||||
|
collect_roles(dependency, indent + 1)
|
||||||
|
|
||||||
|
for root in root_roles:
|
||||||
|
collect_roles(root)
|
||||||
|
|
||||||
|
return printed_roles
|
||||||
|
|
||||||
|
def generate_playbook_entries(roles_dir, prefix=None):
|
||||||
|
"""Generate playbook entries based on the sorted order."""
|
||||||
|
# Build dependency graph
|
||||||
|
graph, in_degree, roles = build_dependency_graph(roles_dir, prefix)
|
||||||
|
|
||||||
|
# Print and collect roles in tree order
|
||||||
|
tree_sorted_roles = print_dependency_tree(graph)
|
||||||
|
|
||||||
|
# Topologically sort the roles
|
||||||
|
sorted_role_names = topological_sort(graph, in_degree)
|
||||||
|
|
||||||
|
# Ensure that roles that appear in the tree come first
|
||||||
|
final_sorted_roles = [role for role in tree_sorted_roles if role in sorted_role_names]
|
||||||
|
|
||||||
|
# Include the remaining unsorted roles
|
||||||
|
final_sorted_roles += [role for role in sorted_role_names if role not in final_sorted_roles]
|
||||||
|
|
||||||
|
# Generate the playbook entries
|
||||||
|
entries = []
|
||||||
|
for role_name in final_sorted_roles:
|
||||||
|
role = roles[role_name]
|
||||||
|
entry = (
|
||||||
|
f"- name: setup {role['application_id']}\n" # Use application_id here
|
||||||
|
f" when: ('{role['application_id']}' in group_names)\n" # Correct condition format
|
||||||
|
f" include_role:\n"
|
||||||
|
f" name: {role['role_name']}\n"
|
||||||
|
)
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Generate an Ansible playbook include file from Docker roles, sorted by run_after order.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'roles_dir',
|
||||||
|
help='Path to directory containing role folders'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-p', '--prefix',
|
||||||
|
help='Only include roles whose names start with this prefix (e.g. docker-, client-)',
|
||||||
|
default=None
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-o', '--output',
|
||||||
|
help='Output file path (default: stdout)',
|
||||||
|
default=None
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-t', '--tree',
|
||||||
|
action='store_true',
|
||||||
|
help='Display the dependency tree of roles visually'
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Generate and output the playbook entries
|
||||||
|
entries = generate_playbook_entries(args.roles_dir, args.prefix)
|
||||||
|
output = ''.join(entries)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
print(f"Playbook entries written to {args.output}")
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
@ -1,8 +1,8 @@
|
|||||||
from ansible.errors import AnsibleFilterError
|
from ansible.errors import AnsibleFilterError
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
class FilterModule(object):
|
class FilterModule(object):
|
||||||
def filters(self):
|
def filters(self):
|
||||||
return {
|
return {
|
||||||
@ -15,48 +15,67 @@ class FilterModule(object):
|
|||||||
1) directly in group_names, or
|
1) directly in group_names, or
|
||||||
2) the application_id of any role reachable (recursively)
|
2) the application_id of any role reachable (recursively)
|
||||||
from any group in group_names via meta/dependencies.
|
from any group in group_names via meta/dependencies.
|
||||||
Expects:
|
|
||||||
- applications: dict mapping application_id → config
|
|
||||||
- group_names: list of active role names
|
|
||||||
"""
|
"""
|
||||||
# validate inputs
|
self._validate_inputs(applications, group_names)
|
||||||
|
|
||||||
|
roles_dir = self._get_roles_directory()
|
||||||
|
|
||||||
|
included_roles = self._collect_reachable_roles(group_names, roles_dir)
|
||||||
|
included_app_ids = self._gather_application_ids(included_roles, roles_dir)
|
||||||
|
|
||||||
|
return self._filter_applications(applications, group_names, included_app_ids)
|
||||||
|
|
||||||
|
def _validate_inputs(self, applications, group_names):
|
||||||
|
"""Validate the inputs for correct types."""
|
||||||
if not isinstance(applications, dict):
|
if not isinstance(applications, dict):
|
||||||
raise AnsibleFilterError(f"Expected applications as dict, got {type(applications).__name__}")
|
raise AnsibleFilterError(f"Expected applications as dict, got {type(applications).__name__}")
|
||||||
if not isinstance(group_names, (list, tuple)):
|
if not isinstance(group_names, (list, tuple)):
|
||||||
raise AnsibleFilterError(f"Expected group_names as list/tuple, got {type(group_names).__name__}")
|
raise AnsibleFilterError(f"Expected group_names as list/tuple, got {type(group_names).__name__}")
|
||||||
|
|
||||||
# locate roles directory (assume plugin sits in filter_plugins/)
|
def _get_roles_directory(self):
|
||||||
|
"""Locate and return the roles directory."""
|
||||||
plugin_dir = os.path.dirname(__file__)
|
plugin_dir = os.path.dirname(__file__)
|
||||||
project_root = os.path.abspath(os.path.join(plugin_dir, '..'))
|
project_root = os.path.abspath(os.path.join(plugin_dir, '..'))
|
||||||
roles_dir = os.path.join(project_root, 'roles')
|
return os.path.join(project_root, 'roles')
|
||||||
|
|
||||||
# recursively collect all roles reachable from the given groups
|
|
||||||
def collect_roles(role, seen):
|
|
||||||
if role in seen:
|
|
||||||
return
|
|
||||||
seen.add(role)
|
|
||||||
meta_file = os.path.join(roles_dir, role, 'meta', 'main.yml')
|
|
||||||
if not os.path.isfile(meta_file):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(meta_file) as f:
|
|
||||||
meta = yaml.safe_load(f) or {}
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
for dep in meta.get('dependencies', []):
|
|
||||||
if isinstance(dep, str):
|
|
||||||
dep_name = dep
|
|
||||||
elif isinstance(dep, dict):
|
|
||||||
dep_name = dep.get('role') or dep.get('name')
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
collect_roles(dep_name, seen)
|
|
||||||
|
|
||||||
|
def _collect_reachable_roles(self, group_names, roles_dir):
|
||||||
|
"""Recursively collect all roles reachable from the given groups via meta/dependencies."""
|
||||||
included_roles = set()
|
included_roles = set()
|
||||||
for grp in group_names:
|
for group in group_names:
|
||||||
collect_roles(grp, included_roles)
|
self._collect_roles_from_group(group, included_roles, roles_dir)
|
||||||
|
return included_roles
|
||||||
|
|
||||||
# gather application_ids from those roles
|
def _collect_roles_from_group(self, group, seen, roles_dir):
|
||||||
|
"""Recursively collect roles from a specific group."""
|
||||||
|
if group in seen:
|
||||||
|
return
|
||||||
|
seen.add(group)
|
||||||
|
|
||||||
|
meta_file = os.path.join(roles_dir, group, 'meta', 'main.yml')
|
||||||
|
if not os.path.isfile(meta_file):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(meta_file) as f:
|
||||||
|
meta = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
for dep in meta.get('dependencies', []):
|
||||||
|
dep_name = self._get_dependency_name(dep)
|
||||||
|
if dep_name:
|
||||||
|
self._collect_roles_from_group(dep_name, seen, roles_dir)
|
||||||
|
|
||||||
|
def _get_dependency_name(self, dependency):
|
||||||
|
"""Extract the dependency role name from the meta data."""
|
||||||
|
if isinstance(dependency, str):
|
||||||
|
return dependency
|
||||||
|
elif isinstance(dependency, dict):
|
||||||
|
return dependency.get('role') or dependency.get('name')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _gather_application_ids(self, included_roles, roles_dir):
|
||||||
|
"""Gather application_ids from the roles."""
|
||||||
included_app_ids = set()
|
included_app_ids = set()
|
||||||
for role in included_roles:
|
for role in included_roles:
|
||||||
vars_file = os.path.join(roles_dir, role, 'vars', 'main.yml')
|
vars_file = os.path.join(roles_dir, role, 'vars', 'main.yml')
|
||||||
@ -67,14 +86,17 @@ class FilterModule(object):
|
|||||||
vars_data = yaml.safe_load(f) or {}
|
vars_data = yaml.safe_load(f) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
app_id = vars_data.get('application_id')
|
app_id = vars_data.get('application_id')
|
||||||
if isinstance(app_id, str) and app_id:
|
if isinstance(app_id, str) and app_id:
|
||||||
included_app_ids.add(app_id)
|
included_app_ids.add(app_id)
|
||||||
|
|
||||||
# build filtered result: include any application whose key is in group_names or in included_app_ids
|
return included_app_ids
|
||||||
|
|
||||||
|
def _filter_applications(self, applications, group_names, included_app_ids):
|
||||||
|
"""Filter and return the applications that match the conditions."""
|
||||||
result = {}
|
result = {}
|
||||||
for app_key, cfg in applications.items():
|
for app_key, cfg in applications.items():
|
||||||
if app_key in group_names or app_key in included_app_ids:
|
if app_key in group_names or app_key in included_app_ids:
|
||||||
result[app_key] = cfg
|
result[app_key] = cfg
|
||||||
|
return result
|
||||||
return result
|
|
||||||
|
@ -5,71 +5,79 @@ class FilterModule(object):
|
|||||||
return {'canonical_domains_map': self.canonical_domains_map}
|
return {'canonical_domains_map': self.canonical_domains_map}
|
||||||
|
|
||||||
def canonical_domains_map(self, apps, primary_domain):
|
def canonical_domains_map(self, apps, primary_domain):
|
||||||
def parse_entry(domains_cfg, key, app_id):
|
"""
|
||||||
if key not in domains_cfg:
|
Maps applications to their canonical domains, checking for conflicts
|
||||||
return None
|
and ensuring all domains are valid and unique across applications.
|
||||||
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 = {}
|
result = {}
|
||||||
seen = {}
|
seen_domains = {}
|
||||||
|
|
||||||
for app_id, cfg in apps.items():
|
for app_id, cfg in apps.items():
|
||||||
domains_cfg = cfg.get('domains')
|
domains_cfg = cfg.get('domains')
|
||||||
if not domains_cfg or 'canonical' not in domains_cfg:
|
if not domains_cfg or 'canonical' not in domains_cfg:
|
||||||
default = f"{app_id}.{primary_domain}"
|
self._add_default_domain(app_id, primary_domain, seen_domains, result)
|
||||||
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
|
continue
|
||||||
|
|
||||||
entry = domains_cfg['canonical']
|
canonical_domains = domains_cfg['canonical']
|
||||||
|
self._process_canonical_domains(app_id, canonical_domains, seen_domains, result)
|
||||||
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
|
return result
|
||||||
|
|
||||||
|
def _add_default_domain(self, app_id, primary_domain, seen_domains, result):
|
||||||
|
"""
|
||||||
|
Add the default domain for an application if no canonical domains are defined.
|
||||||
|
Ensures the domain is unique across applications.
|
||||||
|
"""
|
||||||
|
default_domain = f"{app_id}.{primary_domain}"
|
||||||
|
if default_domain in seen_domains:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"Domain '{default_domain}' is already configured for "
|
||||||
|
f"'{seen_domains[default_domain]}' and '{app_id}'"
|
||||||
|
)
|
||||||
|
seen_domains[default_domain] = app_id
|
||||||
|
result[app_id] = [default_domain]
|
||||||
|
|
||||||
|
def _process_canonical_domains(self, app_id, canonical_domains, seen_domains, result):
|
||||||
|
"""
|
||||||
|
Process the canonical domains for an application, handling both lists and dicts,
|
||||||
|
and ensuring each domain is unique.
|
||||||
|
"""
|
||||||
|
if isinstance(canonical_domains, dict):
|
||||||
|
self._process_canonical_domains_dict(app_id, canonical_domains, seen_domains, result)
|
||||||
|
elif isinstance(canonical_domains, list):
|
||||||
|
self._process_canonical_domains_list(app_id, canonical_domains, seen_domains, result)
|
||||||
|
else:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"Unexpected type for 'domains.canonical' in application '{app_id}': "
|
||||||
|
f"{type(canonical_domains).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_canonical_domains_dict(self, app_id, domains_dict, seen_domains, result):
|
||||||
|
"""
|
||||||
|
Process a dictionary of canonical domains for an application.
|
||||||
|
"""
|
||||||
|
for name, domain in domains_dict.items():
|
||||||
|
self._validate_and_check_domain(app_id, domain, seen_domains)
|
||||||
|
result[app_id] = domains_dict.copy()
|
||||||
|
|
||||||
|
def _process_canonical_domains_list(self, app_id, domains_list, seen_domains, result):
|
||||||
|
"""
|
||||||
|
Process a list of canonical domains for an application.
|
||||||
|
"""
|
||||||
|
for domain in domains_list:
|
||||||
|
self._validate_and_check_domain(app_id, domain, seen_domains)
|
||||||
|
result[app_id] = list(domains_list)
|
||||||
|
|
||||||
|
def _validate_and_check_domain(self, app_id, domain, seen_domains):
|
||||||
|
"""
|
||||||
|
Validate the domain and check if it has already been assigned to another application.
|
||||||
|
"""
|
||||||
|
if not isinstance(domain, str) or not domain.strip():
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"Invalid domain entry in 'canonical' for application '{app_id}': {domain!r}"
|
||||||
|
)
|
||||||
|
if domain in seen_domains:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"Domain '{domain}' is already configured for '{seen_domains[domain]}' and '{app_id}'"
|
||||||
|
)
|
||||||
|
seen_domains[domain] = app_id
|
||||||
|
37
filter_plugins/redirect_filters.py
Normal file
37
filter_plugins/redirect_filters.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# roles/<your-role>/filter_plugins/redirect_filters.py
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
"""
|
||||||
|
Custom filters for redirect domain mappings
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"add_redirect_if_group": self.add_redirect_if_group,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_redirect_if_group(redirect_list, group, source, target, group_names):
|
||||||
|
"""
|
||||||
|
Append {"source": source, "target": target} to *redirect_list*
|
||||||
|
**only** if *group* is contained in *group_names*.
|
||||||
|
|
||||||
|
Usage in Jinja:
|
||||||
|
{{ redirect_list
|
||||||
|
| add_redirect_if_group('lam',
|
||||||
|
'ldap.' ~ primary_domain,
|
||||||
|
domains | get_domain('lam'),
|
||||||
|
group_names) }}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Make a copy so we don’t mutate the original list in place
|
||||||
|
redirects = list(redirect_list)
|
||||||
|
|
||||||
|
if group in group_names:
|
||||||
|
redirects.append({"source": source, "target": target})
|
||||||
|
|
||||||
|
return redirects
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
raise AnsibleFilterError(f"add_redirect_if_group failed: {exc}")
|
@ -30,10 +30,4 @@ defaults_service_provider:
|
|||||||
legal:
|
legal:
|
||||||
editorial_responsible: "Johannes Gutenberg"
|
editorial_responsible: "Johannes Gutenberg"
|
||||||
source_code: "https://github.com/kevinveenbirkenbach/cymais"
|
source_code: "https://github.com/kevinveenbirkenbach/cymais"
|
||||||
imprint: >-
|
imprint: "{{web_protocol}}://{{domains['html-server']}}/imprint.html"
|
||||||
{{ "{protocol}://{domain}/imprint.html"
|
|
||||||
| safe_placeholders({
|
|
||||||
'protocol': web_protocol,
|
|
||||||
'domain': domains.html_server
|
|
||||||
})
|
|
||||||
}}
|
|
@ -23,5 +23,5 @@ galaxy_info:
|
|||||||
issue_tracker_url: https://s.veen.world/cymaisissues
|
issue_tracker_url: https://s.veen.world/cymaisissues
|
||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
dependencies:
|
dependencies:
|
||||||
- role: cleanup-backups-service
|
- cleanup-backups-service
|
||||||
- role: system-maintenance-lock
|
- system-maintenance-lock
|
||||||
|
@ -24,4 +24,4 @@ galaxy_info:
|
|||||||
issue_tracker_url: https://s.veen.world/cymaisissues
|
issue_tracker_url: https://s.veen.world/cymaisissues
|
||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
dependencies:
|
dependencies:
|
||||||
- role: system-aur-helper
|
- system-aur-helper
|
||||||
|
@ -1,8 +1,3 @@
|
|||||||
- name: "Debug: cloudflare_domains"
|
|
||||||
debug:
|
|
||||||
var: cloudflare_domains
|
|
||||||
when: enable_debug
|
|
||||||
|
|
||||||
- name: Create or update Cloudflare A-record for {{ item }}
|
- name: Create or update Cloudflare A-record for {{ item }}
|
||||||
community.general.cloudflare_dns:
|
community.general.cloudflare_dns:
|
||||||
api_token: "{{ cloudflare_api_token }}"
|
api_token: "{{ cloudflare_api_token }}"
|
||||||
|
@ -5,7 +5,7 @@ setup_admin_email: "{{users.administrator.email}}"
|
|||||||
features:
|
features:
|
||||||
matomo: true
|
matomo: true
|
||||||
css: true
|
css: true
|
||||||
portfolio_iframe: false
|
portfolio_iframe: false
|
||||||
central_database: true
|
central_database: true
|
||||||
credentials:
|
credentials:
|
||||||
# database_password: Needs to be defined in inventory file
|
# database_password: Needs to be defined in inventory file
|
||||||
|
@ -28,4 +28,6 @@ galaxy_info:
|
|||||||
issue_tracker_url: https://s.veen.world/cymaisissues
|
issue_tracker_url: https://s.veen.world/cymaisissues
|
||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-chalkboard-teacher"
|
class: "fa-solid fa-chalkboard-teacher"
|
||||||
|
run_after:
|
||||||
|
- docker-keycloak
|
@ -283,7 +283,7 @@ HELP_URL=https://docs.bigbluebutton.org/greenlight/gl-overview.html
|
|||||||
# approval - For approve/decline registration
|
# approval - For approve/decline registration
|
||||||
DEFAULT_REGISTRATION=invite
|
DEFAULT_REGISTRATION=invite
|
||||||
|
|
||||||
{% if applications[application_id].features.oidc | bool %}
|
{% if applications | is_feature_enabled('oidc',application_id) %}
|
||||||
### EXTERNAL AUTHENTICATION METHODS
|
### EXTERNAL AUTHENTICATION METHODS
|
||||||
# @See https://docs.bigbluebutton.org/greenlight/v3/external-authentication/
|
# @See https://docs.bigbluebutton.org/greenlight/v3/external-authentication/
|
||||||
#
|
#
|
||||||
|
@ -19,4 +19,6 @@ galaxy_info:
|
|||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-comments"
|
class: "fa-solid fa-comments"
|
||||||
|
run_after:
|
||||||
|
- docker-wordpress
|
||||||
dependencies: []
|
dependencies: []
|
||||||
|
@ -118,7 +118,7 @@ run:
|
|||||||
## If you want to set the 'From' email address for your first registration, uncomment and change:
|
## If you want to set the 'From' email address for your first registration, uncomment and change:
|
||||||
## After getting the first signup email, re-comment the line. It only needs to run once.
|
## After getting the first signup email, re-comment the line. It only needs to run once.
|
||||||
#- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
|
#- exec: rails r "SiteSetting.notification_email='info@unconfigured.discourse.org'"
|
||||||
{% if applications[application_id].features.oidc | bool %}
|
{% if applications | is_feature_enabled('oidc',application_id) %}
|
||||||
# Deactivate Default Login
|
# Deactivate Default Login
|
||||||
- exec: rails r "SiteSetting.enable_local_logins = false"
|
- exec: rails r "SiteSetting.enable_local_logins = false"
|
||||||
- exec: rails r "SiteSetting.enable_passkeys = false" # https://meta.discourse.org/t/passwordless-login-using-passkeys/285589
|
- exec: rails r "SiteSetting.enable_passkeys = false" # https://meta.discourse.org/t/passwordless-login-using-passkeys/285589
|
||||||
|
@ -77,7 +77,7 @@ ESPOCRM_CONFIG_LDAP_USER_LOGIN_FILTER=(sAMAccountName=%USERNAME%)
|
|||||||
# OpenID Connect settings (optional)
|
# OpenID Connect settings (optional)
|
||||||
# Applied only if the feature flag is true
|
# Applied only if the feature flag is true
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
{% if applications[application_id].features.oidc | bool %}
|
{% if applications | is_feature_enabled('oidc',application_id) %}
|
||||||
|
|
||||||
# ------------------------------------------------
|
# ------------------------------------------------
|
||||||
# OpenID Connect settings
|
# OpenID Connect settings
|
||||||
|
@ -19,4 +19,6 @@ galaxy_info:
|
|||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-lock"
|
class: "fa-solid fa-lock"
|
||||||
|
run_after:
|
||||||
|
- docker-matomo
|
||||||
dependencies: []
|
dependencies: []
|
||||||
|
@ -19,4 +19,6 @@ galaxy_info:
|
|||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-network-wired"
|
class: "fa-solid fa-network-wired"
|
||||||
|
run_after:
|
||||||
|
- docker-keycloak
|
||||||
dependencies: []
|
dependencies: []
|
||||||
|
@ -20,4 +20,6 @@ galaxy_info:
|
|||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-users"
|
class: "fa-solid fa-users"
|
||||||
|
#run_after:
|
||||||
|
# - "0"
|
||||||
dependencies: []
|
dependencies: []
|
||||||
|
@ -17,7 +17,7 @@ listmonk_settings:
|
|||||||
"provider_url": oidc.client.issuer_url,
|
"provider_url": oidc.client.issuer_url,
|
||||||
"client_secret": oidc.client.secret
|
"client_secret": oidc.client.secret
|
||||||
} | to_json }}
|
} | to_json }}
|
||||||
when: applications[application_id].features.oidc | bool
|
when: applications | is_feature_enabled('oidc',application_id)
|
||||||
|
|
||||||
# hCaptcha toggles and credentials
|
# hCaptcha toggles and credentials
|
||||||
- key: "security.enable_captcha"
|
- key: "security.enable_captcha"
|
||||||
|
@ -158,7 +158,7 @@ API_TOKEN={{applications.mailu.credentials.api_token}}
|
|||||||
AUTH_REQUIRE_TOKENS=True
|
AUTH_REQUIRE_TOKENS=True
|
||||||
|
|
||||||
|
|
||||||
{% if applications[application_id].features.oidc | bool %}
|
{% if applications | is_feature_enabled('oidc',application_id) %}
|
||||||
###################################
|
###################################
|
||||||
# OpenID Connect settings
|
# OpenID Connect settings
|
||||||
###################################
|
###################################
|
||||||
|
@ -8,7 +8,7 @@ cert_mount_directory: "{{docker_compose.directories.volumes}}certs/"
|
|||||||
|
|
||||||
# Use dedicated source for oidc if activated
|
# Use dedicated source for oidc if activated
|
||||||
# @see https://github.com/heviat/Mailu-OIDC/tree/2024.06
|
# @see https://github.com/heviat/Mailu-OIDC/tree/2024.06
|
||||||
docker_source: "{{ 'ghcr.io/heviat' if applications[application_id].features.oidc | bool else 'ghcr.io/mailu' }}"
|
docker_source: "{{ 'ghcr.io/heviat' if applications | is_feature_enabled('oidc',application_id) else 'ghcr.io/mailu' }}"
|
||||||
|
|
||||||
domain: "{{ domains | get_domain(application_id) }}"
|
domain: "{{ domains | get_domain(application_id) }}"
|
||||||
http_port: "{{ ports.localhost.http[application_id] }}"
|
http_port: "{{ ports.localhost.http[application_id] }}"
|
@ -21,4 +21,5 @@ galaxy_info:
|
|||||||
documentation: "https://s.veen.world/cymais"
|
documentation: "https://s.veen.world/cymais"
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-bullhorn"
|
class: "fa-solid fa-bullhorn"
|
||||||
dependencies: []
|
run_after:
|
||||||
|
- docker-keycloak
|
@ -52,7 +52,7 @@ SMTP_OPENSSL_VERIFY_MODE=none
|
|||||||
SMTP_ENABLE_STARTTLS=auto
|
SMTP_ENABLE_STARTTLS=auto
|
||||||
SMTP_FROM_ADDRESS=Mastodon <{{ users['no-reply'].email }}>
|
SMTP_FROM_ADDRESS=Mastodon <{{ users['no-reply'].email }}>
|
||||||
|
|
||||||
{% if applications[application_id].features.oidc | bool %}
|
{% if applications | is_feature_enabled('oidc',application_id) %}
|
||||||
###################################
|
###################################
|
||||||
# OpenID Connect settings
|
# OpenID Connect settings
|
||||||
###################################
|
###################################
|
||||||
|
@ -18,4 +18,5 @@ galaxy_info:
|
|||||||
documentation: "https://s.veen.world/cymais"
|
documentation: "https://s.veen.world/cymais"
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-chart-line"
|
class: "fa-solid fa-chart-line"
|
||||||
dependencies: []
|
run_after:
|
||||||
|
- "docker-ldap"
|
@ -20,8 +20,6 @@ oidc:
|
|||||||
# @see https://apps.nextcloud.com/apps/sociallogin
|
# @see https://apps.nextcloud.com/apps/sociallogin
|
||||||
flavor: "oidc_login" # Keeping on sociallogin because the other option is not implemented yet
|
flavor: "oidc_login" # Keeping on sociallogin because the other option is not implemented yet
|
||||||
credentials:
|
credentials:
|
||||||
# database_password: Null # Needs to be set in inventory file
|
|
||||||
# administrator_password: None # Keep in mind to change the password fast after creation and activate 2FA
|
|
||||||
features:
|
features:
|
||||||
matomo: true
|
matomo: true
|
||||||
css: true
|
css: true
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
http_address = "0.0.0.0:4180"
|
http_address = "0.0.0.0:4180"
|
||||||
cookie_secret = "{{ applications[oauth2_proxy_application_id].credentials.oauth2_proxy_cookie_secret }}"
|
cookie_secret = "{{ applications[oauth2_proxy_application_id].credentials.oauth2_proxy_cookie_secret }}"
|
||||||
email_domains = "{{ primary_domain }}"
|
email_domains = "{{ primary_domain }}"
|
||||||
cookie_secure = "true" # True is necessary to force the cookie set via https
|
cookie_secure = "true" # True is necessary to force the cookie set via https
|
||||||
upstreams = "http://{{ applications[oauth2_proxy_application_id].oauth2_proxy.application }}:{{ applications[oauth2_proxy_application_id].oauth2_proxy.port }}"
|
upstreams = "http://{{ applications[oauth2_proxy_application_id].oauth2_proxy.application }}:{{ applications[oauth2_proxy_application_id].oauth2_proxy.port }}"
|
||||||
cookie_domains = ["{{ domains[oauth2_proxy_application_id] }}", "{{ domains | get_domain('keycloak') }}"] # Required so cookie can be read on all subdomains.
|
cookie_domains = ["{{ domains | get_domain(oauth2_proxy_application_id) }}", "{{ domains | get_domain('keycloak') }}"] # Required so cookie can be read on all subdomains.
|
||||||
whitelist_domains = [".{{ primary_domain }}"] # Required to allow redirection back to original requested target.
|
whitelist_domains = [".{{ primary_domain }}"] # Required to allow redirection back to original requested target.
|
||||||
|
|
||||||
# keycloak provider
|
# keycloak provider
|
||||||
client_secret = "{{ oidc.client.secret }}"
|
client_secret = "{{ oidc.client.secret }}"
|
||||||
client_id = "{{ oidc.client.id }}"
|
client_id = "{{ oidc.client.id }}"
|
||||||
redirect_url = "{{ web_protocol }}://{{domains[oauth2_proxy_application_id]}}/oauth2/callback"
|
redirect_url = "{{ web_protocol }}://{{ domains | get_domain(oauth2_proxy_application_id) }}/oauth2/callback"
|
||||||
oidc_issuer_url = "{{ oidc.client.issuer_url }}"
|
oidc_issuer_url = "{{ oidc.client.issuer_url }}"
|
||||||
provider = "oidc"
|
provider = "oidc"
|
||||||
provider_display_name = "Keycloak"
|
provider_display_name = "Keycloak"
|
||||||
|
@ -29,4 +29,5 @@ galaxy_info:
|
|||||||
documentation: "https://s.veen.world/cymais"
|
documentation: "https://s.veen.world/cymais"
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-project-diagram"
|
class: "fa-solid fa-project-diagram"
|
||||||
dependencies: []
|
run_after:
|
||||||
|
- docker-keycloak
|
||||||
|
@ -17,6 +17,8 @@ csp:
|
|||||||
flags:
|
flags:
|
||||||
script-src:
|
script-src:
|
||||||
unsafe-inline: true
|
unsafe-inline: true
|
||||||
|
style-src:
|
||||||
|
unsafe-inline: true
|
||||||
domains:
|
domains:
|
||||||
canonical:
|
canonical:
|
||||||
- "project.{{ primary_domain }}"
|
- "project.{{ primary_domain }}"
|
@ -28,4 +28,5 @@ galaxy_info:
|
|||||||
documentation: "https://s.veen.world/cymais"
|
documentation: "https://s.veen.world/cymais"
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-video"
|
class: "fa-solid fa-video"
|
||||||
dependencies: []
|
run_after:
|
||||||
|
- docker-keycloak
|
||||||
|
5
roles/docker-peertube/tasks/disable-oidc.yml
Normal file
5
roles/docker-peertube/tasks/disable-oidc.yml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
- name: "Uninstall auth-openid-connect plugin for Peertube"
|
||||||
|
command: >
|
||||||
|
docker exec {{ container_name }} \
|
||||||
|
npm run plugin:uninstall -- --npm-name {{oidc_plugin}}
|
||||||
|
ignore_errors: true
|
30
roles/docker-peertube/tasks/enable-oidc.yml
Normal file
30
roles/docker-peertube/tasks/enable-oidc.yml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
- name: "Install auth-openid-connect plugin for Peertube"
|
||||||
|
command: >
|
||||||
|
docker exec {{ container_name }} \
|
||||||
|
npm run plugin:install -- --npm-name {{oidc_plugin}}
|
||||||
|
|
||||||
|
- name: "Update the settings column of the auth-openid-connect plugin"
|
||||||
|
community.postgresql.postgresql_query:
|
||||||
|
db: "{{ database_name }}"
|
||||||
|
login_user: "{{ database_username }}"
|
||||||
|
login_password: "{{ database_password }}"
|
||||||
|
login_host: "127.0.0.1"
|
||||||
|
login_port: "{{ database_port }}"
|
||||||
|
query: |
|
||||||
|
UPDATE plugins
|
||||||
|
SET settings = '{
|
||||||
|
"scope": "openid email profile",
|
||||||
|
"client-id": "{{ oidc.client.id }}",
|
||||||
|
"discover-url": "{{ oidc.client.discovery_document }}",
|
||||||
|
"client-secret": "{{ oidc.client.secret }}",
|
||||||
|
"mail-property": "email",
|
||||||
|
"auth-display-name": "{{ oidc.button_text }}",
|
||||||
|
"username-property": "{{ oidc.attributes.username }}",
|
||||||
|
"signature-algorithm": "RS256",
|
||||||
|
"display-name-property": "{{ oidc.attributes.username }}"
|
||||||
|
}',
|
||||||
|
enabled = TRUE
|
||||||
|
WHERE name = 'auth-openid-connect';
|
||||||
|
when: applications | is_feature_enabled('oidc', application_id)
|
||||||
|
become: true
|
||||||
|
become_user: "{{ container_name }}"
|
@ -13,3 +13,11 @@
|
|||||||
|
|
||||||
- name: "copy docker-compose.yml and env file"
|
- name: "copy docker-compose.yml and env file"
|
||||||
include_tasks: copy-docker-compose-and-env.yml
|
include_tasks: copy-docker-compose-and-env.yml
|
||||||
|
|
||||||
|
- name: "Install and activate auth-openid-connect plugin if OIDC is enabled"
|
||||||
|
include_tasks: enable-oidc.yml
|
||||||
|
when: applications | is_feature_enabled('oidc',application_id)
|
||||||
|
|
||||||
|
- name: "Deinstall and disable auth-openid-connect plugin if OIDC is enabled"
|
||||||
|
include_tasks: disable-oidc.yml
|
||||||
|
when: applications | is_feature_enabled('oidc',application_id)
|
@ -4,6 +4,7 @@ features:
|
|||||||
css: false
|
css: false
|
||||||
portfolio_iframe: false
|
portfolio_iframe: false
|
||||||
central_database: true
|
central_database: true
|
||||||
|
oidc: false
|
||||||
csp:
|
csp:
|
||||||
flags:
|
flags:
|
||||||
script-src:
|
script-src:
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
application_id: "peertube"
|
application_id: "peertube"
|
||||||
database_type: "postgres"
|
database_type: "postgres"
|
||||||
|
container_name: "{{ application_id }}"
|
||||||
|
oidc_plugin: "peertube-plugin-auth-openid-connect"
|
@ -1,5 +1,5 @@
|
|||||||
application_id: "pgadmin"
|
application_id: "pgadmin"
|
||||||
database_type: "postgres"
|
database_type: "postgres"
|
||||||
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id)"
|
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id) }}"
|
||||||
pgadmin_user: 5050
|
pgadmin_user: 5050
|
||||||
pgadmin_group: "{{pgadmin_user}}"
|
pgadmin_group: "{{pgadmin_user}}"
|
@ -1,3 +1,3 @@
|
|||||||
application_id: "phpmyadmin"
|
application_id: "phpmyadmin"
|
||||||
database_type: "mariadb"
|
database_type: "mariadb"
|
||||||
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id)"
|
database_host: "{{ 'central-' + database_type if applications | is_feature_enabled('central_database',application_id) }}"
|
@ -27,4 +27,5 @@ galaxy_info:
|
|||||||
documentation: "https://s.veen.world/cymais"
|
documentation: "https://s.veen.world/cymais"
|
||||||
logo:
|
logo:
|
||||||
class: "fa-solid fa-blog"
|
class: "fa-solid fa-blog"
|
||||||
dependencies: []
|
run_after:
|
||||||
|
- docker-keycloak
|
@ -47,7 +47,7 @@ for filename in os.listdir(config_path):
|
|||||||
# Prepare the URL and expected status codes
|
# Prepare the URL and expected status codes
|
||||||
url = f"{{ web_protocol }}://{domain}"
|
url = f"{{ web_protocol }}://{domain}"
|
||||||
|
|
||||||
redirected_domains = [domain['source'] for domain in {{current_play_redirect_domain_mappings}}]
|
redirected_domains = [domain['source'] for domain in {{ current_play_domain_mappings_redirect}}]
|
||||||
{%- if domains.mailu | safe_var | bool %}
|
{%- if domains.mailu | safe_var | bool %}
|
||||||
redirected_domains.append("{{domains | get_domain('mailu')}}")
|
redirected_domains.append("{{domains | get_domain('mailu')}}")
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
@ -24,5 +24,5 @@ galaxy_info:
|
|||||||
issue_tracker_url: https://s.veen.world/cymaisissues
|
issue_tracker_url: https://s.veen.world/cymaisissues
|
||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
dependencies:
|
dependencies:
|
||||||
- role: docker
|
- docker
|
||||||
- role: nginx-https
|
- nginx-https
|
@ -26,4 +26,4 @@ galaxy_info:
|
|||||||
issue_tracker_url: https://s.veen.world/cymaisissues
|
issue_tracker_url: https://s.veen.world/cymaisissues
|
||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
dependencies:
|
dependencies:
|
||||||
- role: nginx
|
- nginx
|
@ -1,2 +1,2 @@
|
|||||||
application_id: "html_server"
|
application_id: "html-server"
|
||||||
domain: "{{domains | get_domain(application_id)}}"
|
domain: "{{domains | get_domain(application_id)}}"
|
@ -26,6 +26,6 @@ galaxy_info:
|
|||||||
issue_tracker_url: https://s.veen.world/cymaisissues
|
issue_tracker_url: https://s.veen.world/cymaisissues
|
||||||
documentation: https://s.veen.world/cymais
|
documentation: https://s.veen.world/cymais
|
||||||
dependencies:
|
dependencies:
|
||||||
- role: persona-gamer-retro
|
- persona-gamer-retro
|
||||||
- role: persona-gamer-default
|
- persona-gamer-default
|
||||||
- role: persona-gamer-core
|
- persona-gamer-core
|
@ -23,5 +23,5 @@ galaxy_info:
|
|||||||
issue_tracker_url: "https://s.veen.world/cymaisissues"
|
issue_tracker_url: "https://s.veen.world/cymaisissues"
|
||||||
documentation: "https://s.veen.world/cymais"
|
documentation: "https://s.veen.world/cymais"
|
||||||
dependencies:
|
dependencies:
|
||||||
- role: systemd-notifier-telegram
|
- systemd-notifier-telegram
|
||||||
- role: systemd-notifier-email
|
- systemd-notifier-email
|
||||||
|
@ -9,14 +9,17 @@
|
|||||||
set_fact:
|
set_fact:
|
||||||
system_email: "{{ default_system_email | combine(system_email | default({}, true), recursive=True) }}"
|
system_email: "{{ default_system_email | combine(system_email | default({}, true), recursive=True) }}"
|
||||||
|
|
||||||
|
- name: Merge application definitions
|
||||||
|
set_fact:
|
||||||
|
applications: "{{ defaults_applications | combine(applications | default({}, true), recursive=True) }}"
|
||||||
|
|
||||||
- name: Merge current play applications
|
- name: Merge current play applications
|
||||||
set_fact:
|
set_fact:
|
||||||
current_play_applications: >-
|
current_play_applications: >-
|
||||||
{{
|
{{
|
||||||
defaults_applications |
|
applications |
|
||||||
combine(applications | default({}, true), recursive=True) |
|
|
||||||
applications_if_group_and_deps(group_names)
|
applications_if_group_and_deps(group_names)
|
||||||
}}
|
}}
|
||||||
|
|
||||||
- name: Merge current play domain definitions
|
- name: Merge current play domain definitions
|
||||||
set_fact:
|
set_fact:
|
||||||
@ -25,30 +28,7 @@
|
|||||||
canonical_domains_map(primary_domain) |
|
canonical_domains_map(primary_domain) |
|
||||||
combine(domains | default({}, true), recursive=True)
|
combine(domains | default({}, true), recursive=True)
|
||||||
}}
|
}}
|
||||||
|
|
||||||
- name: Set current play all domains incl. www redirect if enabled
|
|
||||||
set_fact:
|
|
||||||
current_play_domains_all: >-
|
|
||||||
{{
|
|
||||||
current_play_domains |
|
|
||||||
generate_all_domains(
|
|
||||||
('www_redirect' in group_names)
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
|
|
||||||
- name: Set current play redirect domain mappings
|
|
||||||
set_fact:
|
|
||||||
current_play_redirect_domain_mappings: >-
|
|
||||||
{{
|
|
||||||
current_play_applications |
|
|
||||||
domain_mappings(primary_domain) |
|
|
||||||
merge_mapping(redirect_domain_mappings, 'source')
|
|
||||||
}}
|
|
||||||
|
|
||||||
- name: Merge application definitions
|
|
||||||
set_fact:
|
|
||||||
applications: "{{ defaults_applications | combine(applications | default({}, true), recursive=True) }}"
|
|
||||||
|
|
||||||
- name: Merge domain definitions for all domains
|
- name: Merge domain definitions for all domains
|
||||||
set_fact:
|
set_fact:
|
||||||
domains: >-
|
domains: >-
|
||||||
@ -58,6 +38,39 @@
|
|||||||
combine(domains | default({}, true), recursive=True)
|
combine(domains | default({}, true), recursive=True)
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
- name: Merge redirect_domain_mappings
|
||||||
|
set_fact:
|
||||||
|
# The following mapping is necessary to define the exceptions for domains which are created, but which aren't used
|
||||||
|
redirect_domain_mappings: "{{
|
||||||
|
[] |
|
||||||
|
add_redirect_if_group('assets-server', domains | get_domain('assets-server'), domains | get_domain('file-server'), group_names) |
|
||||||
|
merge_mapping(redirect_domain_mappings, 'source')
|
||||||
|
}}"
|
||||||
|
|
||||||
|
- name: Set current play redirect domain mappings
|
||||||
|
set_fact:
|
||||||
|
current_play_domain_mappings_redirect: >-
|
||||||
|
{{
|
||||||
|
current_play_applications |
|
||||||
|
domain_mappings(primary_domain) |
|
||||||
|
merge_mapping(redirect_domain_mappings, 'source')
|
||||||
|
}}
|
||||||
|
|
||||||
|
- name: Set current play all domains incl. www redirect if enabled
|
||||||
|
set_fact:
|
||||||
|
current_play_domains_all: >-
|
||||||
|
{{
|
||||||
|
(current_play_domains |
|
||||||
|
combine(
|
||||||
|
current_play_domain_mappings_redirect |
|
||||||
|
items2dict(key_name='target', value_name='source'),
|
||||||
|
recursive=True
|
||||||
|
)) |
|
||||||
|
generate_all_domains(
|
||||||
|
('www_redirect' in group_names)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
- name: Merge networks definitions
|
- name: Merge networks definitions
|
||||||
set_fact:
|
set_fact:
|
||||||
networks: "{{ defaults_networks | combine(networks | default({}, true), recursive=True) }}"
|
networks: "{{ defaults_networks | combine(networks | default({}, true), recursive=True) }}"
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
include_role:
|
include_role:
|
||||||
name: nginx-redirect-domains
|
name: nginx-redirect-domains
|
||||||
vars:
|
vars:
|
||||||
domain_mappings: "{{current_play_redirect_domain_mappings}}"
|
domain_mappings: "{{ current_play_domain_mappings_redirect}}"
|
||||||
|
|
||||||
- name: setup www redirect
|
- name: setup www redirect
|
||||||
when: ("www_redirect" in group_names)
|
when: ("www_redirect" in group_names)
|
||||||
|
67
tests/integration/test_role_dependencies.py
Normal file
67
tests/integration/test_role_dependencies.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
def load_yaml_file(file_path):
|
||||||
|
"""Load a YAML file and return its content."""
|
||||||
|
with open(file_path, 'r') as file:
|
||||||
|
return yaml.safe_load(file) or {}
|
||||||
|
|
||||||
|
def get_meta_info(role_path):
|
||||||
|
"""Extract dependencies from the meta/main.yml of a role."""
|
||||||
|
meta_file = os.path.join(role_path, 'meta', 'main.yml')
|
||||||
|
if not os.path.isfile(meta_file):
|
||||||
|
return []
|
||||||
|
meta_data = load_yaml_file(meta_file)
|
||||||
|
dependencies = meta_data.get('dependencies', [])
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
def resolve_dependencies(roles_dir):
|
||||||
|
"""Resolve all role dependencies and detect circular dependencies."""
|
||||||
|
visited = set() # Tracks roles that have been processed
|
||||||
|
|
||||||
|
def visit(role_path, stack):
|
||||||
|
role_name = os.path.basename(role_path)
|
||||||
|
|
||||||
|
# Check for circular dependencies (if role is already in the current stack)
|
||||||
|
if role_name in stack:
|
||||||
|
raise ValueError(f"Circular dependency detected: {' -> '.join(stack)} -> {role_name}")
|
||||||
|
|
||||||
|
# Check if role is already processed
|
||||||
|
if role_name in visited:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Mark role as visited and add to stack
|
||||||
|
visited.add(role_name)
|
||||||
|
stack.append(role_name)
|
||||||
|
|
||||||
|
# Get dependencies and resolve them
|
||||||
|
dependencies = get_meta_info(role_path)
|
||||||
|
for dep in dependencies:
|
||||||
|
dep_path = os.path.join(roles_dir, dep)
|
||||||
|
visit(dep_path, stack) # Recurse into dependencies
|
||||||
|
|
||||||
|
stack.pop() # Remove the current role from the stack
|
||||||
|
|
||||||
|
for role_name in os.listdir(roles_dir):
|
||||||
|
role_path = os.path.join(roles_dir, role_name)
|
||||||
|
if os.path.isdir(role_path):
|
||||||
|
try:
|
||||||
|
visit(role_path, []) # Start recursion from this role
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Error processing role '{role_name}' at path '{role_path}': {str(e)}")
|
||||||
|
|
||||||
|
class TestRoleDependencies(unittest.TestCase):
|
||||||
|
def test_no_circular_dependencies(self):
|
||||||
|
roles_dir = "roles" # Path to the roles directory
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolve_dependencies(roles_dir)
|
||||||
|
except ValueError as e:
|
||||||
|
self.fail(f"Circular dependency detected: {e}")
|
||||||
|
|
||||||
|
# If no exception, the test passed
|
||||||
|
self.assertTrue(True)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
@ -15,9 +15,9 @@ class TestLoadConfigurationFilter(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
_cfg_cache.clear()
|
_cfg_cache.clear()
|
||||||
self.f = FilterModule().filters()['load_configuration']
|
self.f = FilterModule().filters()['load_configuration']
|
||||||
self.app = 'html_server'
|
self.app = 'html-server'
|
||||||
self.nested_cfg = {
|
self.nested_cfg = {
|
||||||
'html_server': {
|
'html-server': {
|
||||||
'features': {'matomo': True},
|
'features': {'matomo': True},
|
||||||
'domains': {'canonical': ['html.example.com']}
|
'domains': {'canonical': ['html.example.com']}
|
||||||
}
|
}
|
||||||
@ -76,8 +76,8 @@ class TestLoadConfigurationFilter(unittest.TestCase):
|
|||||||
@patch('load_configuration.os.listdir', return_value=['r1'])
|
@patch('load_configuration.os.listdir', return_value=['r1'])
|
||||||
@patch('load_configuration.os.path.isdir', return_value=True)
|
@patch('load_configuration.os.path.isdir', return_value=True)
|
||||||
@patch('load_configuration.os.path.exists', return_value=True)
|
@patch('load_configuration.os.path.exists', return_value=True)
|
||||||
@patch('load_configuration.open', mock_open(read_data="html_server: {}"))
|
@patch('load_configuration.open', mock_open(read_data="html-server: {}"))
|
||||||
@patch('load_configuration.yaml.safe_load', return_value={'html_server': {}})
|
@patch('load_configuration.yaml.safe_load', return_value={'html-server': {}})
|
||||||
def test_key_not_found_after_load(self, *_):
|
def test_key_not_found_after_load(self, *_):
|
||||||
with self.assertRaises(AnsibleFilterError):
|
with self.assertRaises(AnsibleFilterError):
|
||||||
self.f(self.app, 'does.not.exist')
|
self.f(self.app, 'does.not.exist')
|
||||||
|
57
tests/unit/test_redirect_filters.py
Normal file
57
tests/unit/test_redirect_filters.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(
|
||||||
|
0,
|
||||||
|
os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(__file__), "../../")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
from filter_plugins.redirect_filters import FilterModule
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddRedirectIfGroup(unittest.TestCase):
|
||||||
|
"""Unit-tests for the add_redirect_if_group filter."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Obtain the callable once for reuse
|
||||||
|
self.add_redirect = FilterModule().filters()["add_redirect_if_group"]
|
||||||
|
|
||||||
|
def test_appends_redirect_when_group_present(self):
|
||||||
|
original = [{"source": "a", "target": "b"}]
|
||||||
|
result = self.add_redirect(
|
||||||
|
original,
|
||||||
|
group="lam",
|
||||||
|
source="ldap.example.com",
|
||||||
|
target="lam.example.com",
|
||||||
|
group_names=["lam", "other"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Original list must stay unchanged
|
||||||
|
self.assertEqual(len(original), 1)
|
||||||
|
# Result list must contain the extra entry
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertIn(
|
||||||
|
{"source": "ldap.example.com", "target": "lam.example.com"}, result
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_keeps_list_unchanged_when_group_absent(self):
|
||||||
|
original = [{"source": "a", "target": "b"}]
|
||||||
|
result = self.add_redirect(
|
||||||
|
original,
|
||||||
|
group="lam",
|
||||||
|
source="ldap.example.com",
|
||||||
|
target="lam.example.com",
|
||||||
|
group_names=["unrelated"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# No new entries
|
||||||
|
self.assertEqual(result, original)
|
||||||
|
# But ensure a new list object was returned (no in-place mutation)
|
||||||
|
self.assertIsNot(result, original)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user