Compare commits

..

No commits in common. "969a176be128f899054e3bade3bf739f5fca6d5b" and "3c7825fd2307d4e4a8c0b984baa31c09bdb5a7e3" have entirely different histories.

49 changed files with 254 additions and 603 deletions

View File

@ -2,7 +2,7 @@ ROLES_DIR := ./roles
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_playbook.py
INCLUDES_SCRIPT := ./cli/generate-role-includes.py
.PHONY: build install test

View File

@ -0,0 +1,79 @@
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()

View File

@ -1,174 +0,0 @@
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()

View File

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

View File

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

View File

@ -1,37 +0,0 @@
# 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 dont 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}")

View File

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

View File

@ -23,5 +23,5 @@ galaxy_info:
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
dependencies:
- cleanup-backups-service
- system-maintenance-lock
- role: cleanup-backups-service
- role: system-maintenance-lock

View File

@ -24,4 +24,4 @@ galaxy_info:
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
dependencies:
- system-aur-helper
- role: system-aur-helper

View File

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

View File

@ -5,7 +5,7 @@ setup_admin_email: "{{users.administrator.email}}"
features:
matomo: true
css: true
portfolio_iframe: false
portfolio_iframe: false
central_database: true
credentials:
# database_password: Needs to be defined in inventory file

View File

@ -28,6 +28,4 @@ galaxy_info:
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
logo:
class: "fa-solid fa-chalkboard-teacher"
run_after:
- docker-keycloak
class: "fa-solid fa-chalkboard-teacher"

View File

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

View File

@ -19,6 +19,4 @@ galaxy_info:
documentation: https://s.veen.world/cymais
logo:
class: "fa-solid fa-comments"
run_after:
- docker-wordpress
dependencies: []

View File

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

View File

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

View File

@ -19,6 +19,4 @@ galaxy_info:
documentation: https://s.veen.world/cymais
logo:
class: "fa-solid fa-lock"
run_after:
- docker-matomo
dependencies: []

View File

@ -19,6 +19,4 @@ galaxy_info:
documentation: https://s.veen.world/cymais
logo:
class: "fa-solid fa-network-wired"
run_after:
- docker-keycloak
dependencies: []

View File

@ -20,6 +20,4 @@ galaxy_info:
documentation: https://s.veen.world/cymais
logo:
class: "fa-solid fa-users"
#run_after:
# - "0"
dependencies: []

View File

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

View File

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

View File

@ -8,7 +8,7 @@ cert_mount_directory: "{{docker_compose.directories.volumes}}certs/"
# Use dedicated source for oidc if activated
# @see https://github.com/heviat/Mailu-OIDC/tree/2024.06
docker_source: "{{ 'ghcr.io/heviat' if applications | is_feature_enabled('oidc',application_id) 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] }}"

View File

@ -21,5 +21,4 @@ galaxy_info:
documentation: "https://s.veen.world/cymais"
logo:
class: "fa-solid fa-bullhorn"
run_after:
- docker-keycloak
dependencies: []

View File

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

View File

@ -18,5 +18,4 @@ galaxy_info:
documentation: "https://s.veen.world/cymais"
logo:
class: "fa-solid fa-chart-line"
run_after:
- "docker-ldap"
dependencies: []

View File

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

View File

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

View File

@ -29,5 +29,4 @@ galaxy_info:
documentation: "https://s.veen.world/cymais"
logo:
class: "fa-solid fa-project-diagram"
run_after:
- docker-keycloak
dependencies: []

View File

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

View File

@ -28,5 +28,4 @@ galaxy_info:
documentation: "https://s.veen.world/cymais"
logo:
class: "fa-solid fa-video"
run_after:
- docker-keycloak
dependencies: []

View File

@ -1,5 +0,0 @@
- name: "Uninstall auth-openid-connect plugin for Peertube"
command: >
docker exec {{ container_name }} \
npm run plugin:uninstall -- --npm-name {{oidc_plugin}}
ignore_errors: true

View File

@ -1,30 +0,0 @@
- 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 }}"

View File

@ -13,11 +13,3 @@
- name: "copy docker-compose.yml and env file"
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)

View File

@ -4,7 +4,6 @@ features:
css: false
portfolio_iframe: false
central_database: true
oidc: false
csp:
flags:
script-src:

View File

@ -1,4 +1,2 @@
application_id: "peertube"
database_type: "postgres"
container_name: "{{ application_id }}"
oidc_plugin: "peertube-plugin-auth-openid-connect"
application_id: "peertube"
database_type: "postgres"

View File

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

View File

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

View File

@ -27,5 +27,4 @@ galaxy_info:
documentation: "https://s.veen.world/cymais"
logo:
class: "fa-solid fa-blog"
run_after:
- docker-keycloak
dependencies: []

View File

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

View File

@ -24,5 +24,5 @@ galaxy_info:
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
dependencies:
- docker
- nginx-https
- role: docker
- role: nginx-https

View File

@ -26,4 +26,4 @@ galaxy_info:
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
dependencies:
- nginx
- role: nginx

View File

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

View File

@ -26,6 +26,6 @@ galaxy_info:
issue_tracker_url: https://s.veen.world/cymaisissues
documentation: https://s.veen.world/cymais
dependencies:
- persona-gamer-retro
- persona-gamer-default
- persona-gamer-core
- role: persona-gamer-retro
- role: persona-gamer-default
- role: persona-gamer-core

View File

@ -23,5 +23,5 @@ galaxy_info:
issue_tracker_url: "https://s.veen.world/cymaisissues"
documentation: "https://s.veen.world/cymais"
dependencies:
- systemd-notifier-telegram
- systemd-notifier-email
- role: systemd-notifier-telegram
- role: systemd-notifier-email

View File

@ -9,17 +9,14 @@
set_fact:
system_email: "{{ default_system_email | combine(system_email | default({}, true), recursive=True) }}"
- name: Merge application definitions
set_fact:
applications: "{{ defaults_applications | combine(applications | default({}, true), recursive=True) }}"
- name: Merge current play applications
set_fact:
current_play_applications: >-
{{
applications |
defaults_applications |
combine(applications | default({}, true), recursive=True) |
applications_if_group_and_deps(group_names)
}}
}}
- name: Merge current play domain definitions
set_fact:
@ -28,7 +25,30 @@
canonical_domains_map(primary_domain) |
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
set_fact:
domains: >-
@ -38,39 +58,6 @@
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
set_fact:
networks: "{{ defaults_networks | combine(networks | default({}, true), recursive=True) }}"

View File

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

View File

@ -1,67 +0,0 @@
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()

View File

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

View File

@ -1,57 +0,0 @@
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()