diff --git a/cli/build/inventory/full.py b/cli/build/inventory/full.py new file mode 100644 index 00000000..db833f96 --- /dev/null +++ b/cli/build/inventory/full.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# cli/build/inventory/full.py + +import argparse +import sys +import os + +try: + from filter_plugins.get_all_invokable_apps import get_all_invokable_apps +except ImportError: + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))) + from filter_plugins.get_all_invokable_apps import get_all_invokable_apps + +import yaml +import json + +def build_group_inventory(apps, host): + """ + Builds a group-based Ansible inventory: each app is a group containing the host. + """ + groups = {app: {"hosts": [host]} for app in apps} + inventory = { + "all": { + "hosts": [host], + "children": {app: {} for app in apps}, + }, + **groups + } + return inventory + +def build_hostvar_inventory(apps, host): + """ + Alternative: Builds an inventory where all invokables are set as hostvars (as a list). + """ + return { + "all": { + "hosts": [host], + }, + "_meta": { + "hostvars": { + host: { + "invokable_applications": apps + } + } + } + } + +def main(): + parser = argparse.ArgumentParser( + description='Build a dynamic Ansible inventory for a given host with all invokable applications.' + ) + parser.add_argument( + '--host', + required=True, + help='Hostname to assign to all invokable application groups' + ) + parser.add_argument( + '-f', '--format', + choices=['json', 'yaml'], + default='yaml', + help='Output format (yaml [default], json)' + ) + parser.add_argument( + '--inventory-style', + choices=['group', 'hostvars'], + default='group', + help='Inventory style: group (default, one group per app) or hostvars (list as hostvar)' + ) + parser.add_argument( + '-c', '--categories-file', + default=os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'roles', 'categories.yml')), + help='Path to roles/categories.yml (default: roles/categories.yml at project root)' + ) + parser.add_argument( + '-r', '--roles-dir', + default=os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'roles')), + help='Path to roles/ directory (default: roles/ at project root)' + ) + parser.add_argument( + '-o', '--output', + help='Write output to file instead of stdout' + ) + args = parser.parse_args() + + try: + apps = get_all_invokable_apps( + categories_file=args.categories_file, + roles_dir=args.roles_dir + ) + except Exception as e: + sys.stderr.write(f"Error: {e}\n") + sys.exit(1) + + # Select inventory style + if args.inventory_style == 'group': + inventory = build_group_inventory(apps, args.host) + else: + inventory = build_hostvar_inventory(apps, args.host) + + # Output in chosen format + if args.format == 'json': + output = json.dumps(inventory, indent=2) + else: + output = yaml.safe_dump(inventory, default_flow_style=False) + + if args.output: + with open(args.output, 'w') as f: + f.write(output) + else: + print(output) + +if __name__ == '__main__': + main() diff --git a/cli/meta/applications/invokable.py b/cli/meta/applications/invokable.py new file mode 100644 index 00000000..fdfc2915 --- /dev/null +++ b/cli/meta/applications/invokable.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# cli/meta/applications/invokable.py + +import argparse +import sys +import os + +# Import filter plugin for get_all_invokable_apps +try: + from filter_plugins.get_all_invokable_apps import get_all_invokable_apps +except ImportError: + # Try to adjust sys.path if running outside Ansible + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))) + try: + from filter_plugins.get_all_invokable_apps import get_all_invokable_apps + except ImportError: + sys.stderr.write("Could not import filter_plugins.get_all_invokable_apps. Check your PYTHONPATH.\n") + sys.exit(1) + +def main(): + parser = argparse.ArgumentParser( + description='List all invokable applications (application_ids) based on invokable paths from categories.yml and available roles.' + ) + parser.add_argument( + '-c', '--categories-file', + default=os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'roles', 'categories.yml')), + help='Path to roles/categories.yml (default: roles/categories.yml at project root)' + ) + parser.add_argument( + '-r', '--roles-dir', + default=os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'roles')), + help='Path to roles/ directory (default: roles/ at project root)' + ) + args = parser.parse_args() + + try: + result = get_all_invokable_apps( + categories_file=args.categories_file, + roles_dir=args.roles_dir + ) + except Exception as e: + sys.stderr.write(f"Error: {e}\n") + sys.exit(1) + + for app_id in result: + print(app_id) + +if __name__ == '__main__': + main() diff --git a/filter_plugins/get_all_invokable_apps.py b/filter_plugins/get_all_invokable_apps.py new file mode 100644 index 00000000..58eea296 --- /dev/null +++ b/filter_plugins/get_all_invokable_apps.py @@ -0,0 +1,54 @@ +import os +import yaml + +def get_all_invokable_apps( + categories_file=None, + roles_dir=None +): + """ + Return all application_ids (or role names) for roles whose directory names match invokable paths from categories.yml. + :param categories_file: Path to categories.yml (default: roles/categories.yml at project root) + :param roles_dir: Path to roles directory (default: roles/ at project root) + :return: List of application_ids (or role names) + """ + # Resolve defaults + here = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(here, '..')) + if not categories_file: + categories_file = os.path.join(project_root, 'roles', 'categories.yml') + if not roles_dir: + roles_dir = os.path.join(project_root, 'roles') + + # Get invokable paths + from filter_plugins.invokable_paths import get_invokable_paths + invokable_paths = get_invokable_paths(categories_file) + if not invokable_paths: + return [] + + result = [] + if not os.path.isdir(roles_dir): + return [] + + for role in sorted(os.listdir(roles_dir)): + role_path = os.path.join(roles_dir, role) + if not os.path.isdir(role_path): + continue + if any(role == p or role.startswith(p + '-') for p in invokable_paths): + vars_file = os.path.join(role_path, 'vars', 'main.yml') + if os.path.isfile(vars_file): + try: + with open(vars_file, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) or {} + app_id = data.get('application_id', role) + except Exception: + app_id = role + else: + app_id = role + result.append(app_id) + return sorted(result) + +class FilterModule(object): + def filters(self): + return { + 'get_all_invokable_apps': get_all_invokable_apps + } diff --git a/filter_plugins/get_entity_name.py b/filter_plugins/get_entity_name.py new file mode 100644 index 00000000..4973f16e --- /dev/null +++ b/filter_plugins/get_entity_name.py @@ -0,0 +1,55 @@ +import os +import yaml + +def _load_categories_tree(categories_file): + with open(categories_file, 'r', encoding='utf-8') as f: + categories = yaml.safe_load(f)['roles'] + return categories + +def _flatten_categories(tree, prefix=''): + """Flattens nested category tree to all possible category paths.""" + result = [] + for k, v in tree.items(): + current = f"{prefix}-{k}" if prefix else k + result.append(current) + if isinstance(v, dict): + for sk, sv in v.items(): + if isinstance(sv, dict): + result.extend(_flatten_categories({sk: sv}, current)) + return result + +def get_entity_name(role_name): + """ + Automatically get the entity name from a role name by removing the + longest matching category path from categories.yml. + """ + possible_locations = [ + os.path.join(os.getcwd(), 'roles', 'categories.yml'), + os.path.join(os.path.dirname(__file__), '..', 'roles', 'categories.yml'), + 'roles/categories.yml', + ] + categories_file = None + for loc in possible_locations: + if os.path.exists(loc): + categories_file = loc + break + if not categories_file: + return role_name + + categories_tree = _load_categories_tree(categories_file) + all_category_paths = _flatten_categories(categories_tree) + + role_name_lc = role_name.lower() + all_category_paths = [cat.lower() for cat in all_category_paths] + for cat in sorted(all_category_paths, key=len, reverse=True): + if role_name_lc.startswith(cat + "-"): + return role_name[len(cat) + 1:] + if role_name_lc == cat: + return "" + return role_name + +class FilterModule(object): + def filters(self): + return { + 'get_entity_name': get_entity_name, + } diff --git a/group_vars/all/09_ports.yml b/group_vars/all/09_ports.yml index 5f134350..446477bd 100644 --- a/group_vars/all/09_ports.yml +++ b/group_vars/all/09_ports.yml @@ -27,7 +27,7 @@ ports: web-app-mediawiki: 8004 web-app-mybb: 8005 yourls: 8006 - mailu: 8007 + web-app-mailu: 8007 web-app-elk: 8008 web-app-mastodon: 8009 web-app-pixelfed: 8010 diff --git a/group_vars/all/10_networks.yml b/group_vars/all/10_networks.yml index 90f82e61..234144a8 100644 --- a/group_vars/all/10_networks.yml +++ b/group_vars/all/10_networks.yml @@ -42,7 +42,7 @@ defaults_networks: subnet: 192.168.101.240/28 web-app-matrix: subnet: 192.168.102.0/28 - mailu: + web-app-mailu: # Use one of the last container ips for dns resolving so that it isn't used dns: 192.168.102.29 subnet: 192.168.102.16/28 diff --git a/group_vars/all/15_about.yml b/group_vars/all/15_about.yml index a062967c..820f640f 100644 --- a/group_vars/all/15_about.yml +++ b/group_vars/all/15_about.yml @@ -19,7 +19,7 @@ defaults_service_provider: bluesky: >- {{ ('@' ~ users.contact.username ~ '.' ~ domains.bluesky.api) if 'bluesky' in group_names else '' }} - email: "{{ users.contact.username ~ '@' ~ primary_domain if 'mailu' in group_names else '' }}" + email: "{{ users.contact.username ~ '@' ~ primary_domain if 'web-app-mailu' in group_names else '' }}" mastodon: "{{ '@' ~ users.contact.username ~ '@' ~ domains | get_domain('web-app-mastodon') if 'web-app-mastodon' in group_names else '' }}" matrix: "{{ '@' ~ users.contact.username ~ ':' ~ domains['web-app-matrix'].synapse if 'web-app-matrix' in group_names else '' }}" peertube: "{{ '@' ~ users.contact.username ~ '@' ~ domains | get_domain('web-app-peertube') if 'web-app-peertube' in group_names else '' }}" diff --git a/roles/cmp-rdbms/templates/services/mariadb.yml.j2 b/roles/cmp-rdbms/templates/services/mariadb.yml.j2 index e820ef34..b61dbc1f 100644 --- a/roles/cmp-rdbms/templates/services/mariadb.yml.j2 +++ b/roles/cmp-rdbms/templates/services/mariadb.yml.j2 @@ -13,9 +13,9 @@ - database:/var/lib/mysql healthcheck: test: [ "CMD", "sh", "-c", "/usr/bin/mariadb --user=$$MYSQL_USER --password=$$MYSQL_PASSWORD --execute 'SHOW DATABASES;'" ] - interval: 3s - timeout: 1s - retries: 5 + interval: 10s + timeout: 5s + retries: 18 networks: - default {% endif %} diff --git a/roles/cmp-rdbms/vars/database.yml b/roles/cmp-rdbms/vars/database.yml index e438c35a..b04c0d15 100644 --- a/roles/cmp-rdbms/vars/database.yml +++ b/roles/cmp-rdbms/vars/database.yml @@ -1,14 +1,17 @@ # Helper variables -_database_id: "svc-db-{{ database_type }}" -_database_central_name: "{{ applications | get_app_conf( _database_id, 'docker.services.' ~ database_type ~ '.name') }}" +_database_id: "svc-db-{{ database_type }}" +_database_central_name: "{{ applications | get_app_conf( _database_id, 'docker.services.' ~ database_type ~ '.name') }}" +_database_consumer_public_id: "{{ database_application_id | get_public_id }}" +_database_central_enabled: "{{ applications | get_app_conf(database_application_id, 'features.central_database', False) }}" # Definition -database_name: "{{ applications | get_app_conf( database_application_id, 'database.name', false, database_application_id | get_public_id ) }}" # The overwritte configuration is needed by bigbluebutton -database_instance: "{{ _database_central_name if applications | get_app_conf(database_application_id, 'features.central_database', False) else database_name }}" # This could lead to bugs at dedicated database @todo cleanup -database_host: "{{ _database_central_name if applications | get_app_conf(database_application_id, 'features.central_database', False) else 'database' }}" # This could lead to bugs at dedicated database @todo cleanup -database_username: "{{ applications | get_app_conf(database_application_id, 'database.username', false, database_application_id | get_public_id)}}" # The overwritte configuration is needed by bigbluebutton +database_name: "{{ applications | get_app_conf( database_application_id, 'database.name', false, _database_consumer_public_id ) }}" # The overwritte configuration is needed by bigbluebutton +database_instance: "{{ _database_central_name if _database_central_enabled else database_name }}" # This could lead to bugs at dedicated database @todo cleanup +database_host: "{{ _database_central_name if _database_central_enabled else 'database' }}" # This could lead to bugs at dedicated database @todo cleanup +database_username: "{{ applications | get_app_conf(database_application_id, 'database.username', false, _database_consumer_public_id)}}" # The overwritte configuration is needed by bigbluebutton database_password: "{{ applications | get_app_conf(database_application_id, 'credentials.database_password', true) }}" database_port: "{{ ports.localhost.database[ _database_id ] }}" database_env: "{{docker_compose.directories.env}}{{database_type}}.env" database_url_jdbc: "jdbc:{{ database_type if database_type == 'mariadb' else 'postgresql' }}://{{ database_host }}:{{ database_port }}/{{ database_name }}" -database_url_full: "{{database_type}}://{{database_username}}:{{database_password}}@{{database_host}}:{{database_port}}/{{ database_name }}" \ No newline at end of file +database_url_full: "{{database_type}}://{{database_username}}:{{database_password}}@{{database_host}}:{{database_port}}/{{ database_name }}" +database_volume: "{{ _database_consumer_public_id ~ '_' if not _database_central_enabled }}{{ database_host }}" diff --git a/roles/docker-compose/templates/volumes-just-database.yml.j2 b/roles/docker-compose/templates/volumes-just-database.yml.j2 index 11bdc5e8..fe8f6011 100644 --- a/roles/docker-compose/templates/volumes-just-database.yml.j2 +++ b/roles/docker-compose/templates/volumes-just-database.yml.j2 @@ -2,5 +2,6 @@ {% if not applications | get_app_conf(application_id, 'features.central_database', False)%} volumes: database: + name: {{ database_volume }} {% endif %} {{ "\n" }} \ No newline at end of file diff --git a/roles/docker-compose/templates/volumes.yml.j2 b/roles/docker-compose/templates/volumes.yml.j2 index 29dbf58e..9dfae1fe 100644 --- a/roles/docker-compose/templates/volumes.yml.j2 +++ b/roles/docker-compose/templates/volumes.yml.j2 @@ -2,5 +2,6 @@ volumes: {% if not applications | get_app_conf(application_id, 'features.central_database', False)%} database: + name: {{ database_volume }} {% endif %} {{ "\n" }} \ No newline at end of file diff --git a/roles/docker-core/Todo.md b/roles/docker-core/Todo.md new file mode 100644 index 00000000..15efc1c4 --- /dev/null +++ b/roles/docker-core/Todo.md @@ -0,0 +1,2 @@ +# Todos +- Add cleanup service for docker system prune -f \ No newline at end of file diff --git a/roles/svc-db-mariadb/tasks/main.yml b/roles/svc-db-mariadb/tasks/main.yml index 0817ddb0..04ad8e9b 100644 --- a/roles/svc-db-mariadb/tasks/main.yml +++ b/roles/svc-db-mariadb/tasks/main.yml @@ -24,9 +24,9 @@ restart_policy: "{{ docker_restart_policy }}" healthcheck: test: "/usr/bin/mariadb --user=root --password={{ mariadb_root_pwd }} --execute \"SHOW DATABASES;\"" - interval: 3s - timeout: 1s - retries: 5 + interval: 10s + timeout: 5s + retries: 18 when: run_once_docker_mariadb is not defined register: setup_mariadb_container_result diff --git a/roles/svc-db-redis/templates/service.yml.j2 b/roles/svc-db-redis/templates/service.yml.j2 index e97a13be..85abb79a 100644 --- a/roles/svc-db-redis/templates/service.yml.j2 +++ b/roles/svc-db-redis/templates/service.yml.j2 @@ -3,7 +3,7 @@ {% set redis_version = applications | get_app_conf('svc-db-redis', 'docker.services.redis.version')%} redis: image: "{{ redis_image }}:{{ redis_version }}" - container_name: {{ application_id }}-redis + container_name: {{ application_id | get_public_id }}-redis restart: {{ docker_restart_policy }} logging: driver: journald diff --git a/roles/sys-hlth-webserver/templates/sys-hlth-webserver.py.j2 b/roles/sys-hlth-webserver/templates/sys-hlth-webserver.py.j2 index debcb0b9..b08b4d58 100644 --- a/roles/sys-hlth-webserver/templates/sys-hlth-webserver.py.j2 +++ b/roles/sys-hlth-webserver/templates/sys-hlth-webserver.py.j2 @@ -43,7 +43,7 @@ for filename in os.listdir(config_path): url = f"{{ web_protocol }}://{domain}" redirected_domains = [domain['source'] for domain in {{ current_play_domain_mappings_redirect}}] - redirected_domains.append("{{domains | get_domain('mailu')}}") + redirected_domains.append("{{domains | get_domain('web-app-mailu')}}") expected_statuses = get_expected_statuses(domain, parts, redirected_domains) diff --git a/roles/sys-rpr-docker-hard/files/sys-rpr-docker-hard.py b/roles/sys-rpr-docker-hard/files/sys-rpr-docker-hard.py index 6e0c429e..c9194cc3 100644 --- a/roles/sys-rpr-docker-hard/files/sys-rpr-docker-hard.py +++ b/roles/sys-rpr-docker-hard/files/sys-rpr-docker-hard.py @@ -43,7 +43,7 @@ if __name__ == "__main__": if os.path.isfile(docker_compose_file): print(f"Found docker-compose.yml in {dir_path}.") - if dir_name == "mailu": + if dir_name == "web-app-mailu": print(f"Directory {dir_name} detected. Performing hard restart...") hard_restart_docker_services(dir_path) else: diff --git a/roles/web-app-attendize/tasks/main.yml b/roles/web-app-attendize/tasks/main.yml index 351cdaa6..585980ec 100644 --- a/roles/web-app-attendize/tasks/main.yml +++ b/roles/web-app-attendize/tasks/main.yml @@ -10,7 +10,7 @@ domain: "{{ item }}" http_port: "{{ ports.localhost.http[application_id] }}" loop: - - "{{ domains | get_domain('mailu') }}" + - "{{ domains | get_domain('web-app-mailu') }}" - "{{ domain }}" - name: "For '{{ application_id }}': configure {{domains | get_domain(application_id)}}.conf" diff --git a/roles/web-app-espocrm/config/main.yml b/roles/web-app-espocrm/config/main.yml index 4f5d78f9..57043f1e 100644 --- a/roles/web-app-espocrm/config/main.yml +++ b/roles/web-app-espocrm/config/main.yml @@ -33,5 +33,5 @@ docker: image: "espocrm/espocrm" version: "latest" name: "espocrm" - volumes: - data: espocrm_data \ No newline at end of file + volumes: + data: espocrm_data \ No newline at end of file diff --git a/roles/web-app-espocrm/tasks/database.yml b/roles/web-app-espocrm/tasks/database.yml new file mode 100644 index 00000000..49f940c0 --- /dev/null +++ b/roles/web-app-espocrm/tasks/database.yml @@ -0,0 +1,32 @@ +- name: Check if config.php exists in EspoCRM + command: docker exec --user root {{ espocrm_name }} test -f {{ espocrm_config_file }} + register: config_file_exists + changed_when: false + failed_when: false + +- name: Patch EspoCRM config.php with updated DB credentials + when: config_file_exists.rc == 0 + block: + - name: Update DB host + command: > + docker exec --user root {{ espocrm_name }} + sed -i "s/'host' => .*/'host' => '{{ database_host }}',/" {{ espocrm_config_file }} + notify: docker compose up + + - name: Update DB name + command: > + docker exec --user root {{ espocrm_name }} + sed -i "s/'dbname' => .*/'dbname' => '{{ database_name }}',/" {{ espocrm_config_file }} + notify: docker compose up + + - name: Update DB user + command: > + docker exec --user root {{ espocrm_name }} + sed -i "s/'user' => .*/'user' => '{{ database_username }}',/" {{ espocrm_config_file }} + notify: docker compose up + + - name: Update DB password + command: > + docker exec --user root {{ espocrm_name }} + sed -i "s/'password' => .*/'password' => '{{ database_password }}',/" {{ espocrm_config_file }} + notify: docker compose up diff --git a/roles/web-app-espocrm/tasks/main.yml b/roles/web-app-espocrm/tasks/main.yml index eb94f285..05fea694 100644 --- a/roles/web-app-espocrm/tasks/main.yml +++ b/roles/web-app-espocrm/tasks/main.yml @@ -3,6 +3,13 @@ include_role: name: cmp-db-docker-proxy +- name: Update database credentials + include_tasks: database.yml + +- name: Flush handlers to make DB available before password reset + meta: flush_handlers + when: docker_compose_flush_handlers | bool + - name: Set OIDC scopes in EspoCRM config (inside web container) ansible.builtin.shell: | docker compose exec -T web php -r ' diff --git a/roles/web-app-espocrm/vars/main.yml b/roles/web-app-espocrm/vars/main.yml index 26d79efe..6e4b47e6 100644 --- a/roles/web-app-espocrm/vars/main.yml +++ b/roles/web-app-espocrm/vars/main.yml @@ -8,4 +8,5 @@ docker_compose_flush_handlers: true espocrm_version: "{{ applications | get_app_conf(application_id, 'docker.services.espocrm.version', True) }}" espocrm_image: "{{ applications | get_app_conf(application_id, 'docker.services.espocrm.image', True) }}" espocrm_name: "{{ applications | get_app_conf(application_id, 'docker.services.espocrm.name', True) }}" -espocrm_volume: "{{ applications | get_app_conf(application_id, 'docker.volumes.data', True) }}" \ No newline at end of file +espocrm_volume: "{{ applications | get_app_conf(application_id, 'docker.volumes.data', True) }}" +espocrm_config_file: "/var/www/html/data/config-internal.php" \ No newline at end of file diff --git a/roles/web-app-mailu/Todo.md b/roles/web-app-mailu/Todo.md new file mode 100644 index 00000000..81710cfc --- /dev/null +++ b/roles/web-app-mailu/Todo.md @@ -0,0 +1,2 @@ +# Todos +- Implement hard restart into Backup for mailu \ No newline at end of file diff --git a/roles/web-app-mailu/config/main.yml b/roles/web-app-mailu/config/main.yml index 940e992c..7908cfca 100644 --- a/roles/web-app-mailu/config/main.yml +++ b/roles/web-app-mailu/config/main.yml @@ -1,4 +1,3 @@ -version: "2024.06" # Docker Image Version oidc: email_by_username: true # If true, then the mail is set by the username. If wrong then the OIDC user email is used enable_user_creation: true # Users will be created if not existing @@ -30,4 +29,7 @@ docker: redis: enabled: true database: - enabled: true \ No newline at end of file + enabled: true + mailu: + version: "2024.06" # Docker Image Version + name: mailu \ No newline at end of file diff --git a/roles/web-app-mailu/tasks/main.yml b/roles/web-app-mailu/tasks/main.yml index a413474c..5c95d018 100644 --- a/roles/web-app-mailu/tasks/main.yml +++ b/roles/web-app-mailu/tasks/main.yml @@ -19,7 +19,7 @@ mailu_compose_dir: "{{ docker_compose.directories.instance }}" mailu_domain: "{{ primary_domain }}" mailu_api_base_url: "http://127.0.0.1:8080/api/v1" - mailu_global_api_token: "{{ applications.mailu.credentials.api_token }}" + mailu_global_api_token: "{{ applications | get_app_conf(application_id, 'credentials.api_token') }}" mailu_action: >- {{ ( diff --git a/roles/web-app-mailu/templates/docker-compose.yml.j2 b/roles/web-app-mailu/templates/docker-compose.yml.j2 index 064904cb..ff3463fb 100644 --- a/roles/web-app-mailu/templates/docker-compose.yml.j2 +++ b/roles/web-app-mailu/templates/docker-compose.yml.j2 @@ -2,13 +2,15 @@ # Core services resolver: - image: {{docker_source}}/unbound:{{applications.mailu.version}} + image: {{docker_source}}/unbound:{{ mailu_version }} + container_name: {{mailu_name}}_resolver {% include 'roles/docker-container/templates/base.yml.j2' %} {% include 'roles/docker-container/templates/networks.yml.j2' %} - ipv4_address: {{networks.local.mailu.dns}} + ipv4_address: {{networks.local['web-app-mailu'].dns}} front: - image: {{docker_source}}/nginx:{{applications.mailu.version}} + container_name: {{mailu_name}}_front + image: {{docker_source}}/nginx:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} ports: - "127.0.0.1:{{ports.localhost.http[application_id]}}:80" @@ -30,10 +32,11 @@ webmail: radicale: dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} admin: - image: {{docker_source}}/admin:{{applications.mailu.version}} + container_name: {{mailu_name}}_admin + image: {{docker_source}}/admin:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - "admin_data:/data" @@ -44,11 +47,12 @@ front: condition: service_started dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} imap: - image: {{docker_source}}/dovecot:{{applications.mailu.version}} + container_name: {{mailu_name}}_imap + image: {{docker_source}}/dovecot:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - "dovecot_mail:/mail" @@ -57,11 +61,12 @@ - front - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} smtp: - image: {{docker_source}}/postfix:{{applications.mailu.version}} + container_name: {{mailu_name}}_smtp + image: {{docker_source}}/postfix:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - "{{docker_compose.directories.volumes}}overrides:/overrides:ro" @@ -70,22 +75,24 @@ - front - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} oletools: - image: {{docker_source}}/oletools:{{applications.mailu.version}} + container_name: {{mailu_name}}_oletools + image: {{docker_source}}/oletools:{{ mailu_version }} hostname: oletools restart: {{docker_restart_policy}} depends_on: - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} noinet: antispam: - image: {{docker_source}}/rspamd:{{applications.mailu.version}} + container_name: {{mailu_name}}_antispam + image: {{docker_source}}/rspamd:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - "filter:/var/lib/rspamd" @@ -97,13 +104,14 @@ - antivirus - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} noinet: # Optional services antivirus: + container_name: {{mailu_name}}_antivirus image: clamav/clamav-debian:latest {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: @@ -111,23 +119,25 @@ depends_on: - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} webdav: - image: {{docker_source}}/radicale:{{applications.mailu.version}} + container_name: {{mailu_name}}_webdav + image: {{docker_source}}/radicale:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - "webdav_data:/data" depends_on: - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} radicale: fetchmail: - image: {{docker_source}}/fetchmail:{{applications.mailu.version}} + container_name: {{mailu_name}}_fetchmail + image: {{docker_source}}/fetchmail:{{ mailu_version }} volumes: - "admin_data:/data" {% include 'roles/docker-container/templates/base.yml.j2' %} @@ -137,11 +147,12 @@ - imap - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} webmail: - image: {{docker_source}}/webmail:{{applications.mailu.version}} + container_name: {{mailu_name}}_webmail + image: {{docker_source}}/webmail:{{ mailu_version }} {% include 'roles/docker-container/templates/base.yml.j2' %} volumes: - "webmail_data:/data" @@ -151,19 +162,27 @@ - front - resolver dns: - - {{networks.local.mailu.dns}} + - {{networks.local['web-app-mailu'].dns}} {% include 'roles/docker-container/templates/networks.yml.j2' %} webmail: {% include 'roles/docker-compose/templates/volumes.yml.j2' %} smtp_queue: + name: {{ mailu_smtp_queue }} admin_data: + name: {{ mailu_admin_data }} webdav_data: + name: {{ mailu_webdav_data }} webmail_data: + name: {{ mailu_webmail_data }} filter: + name: {{ mailu_filter }} dkim: + name: {{ mailu_dkim }} dovecot_mail: + name: {{ mailu_dovecot_mail }} redis: + name: {{ mailu_redis }} {% include 'roles/docker-compose/templates/networks.yml.j2' %} radicale: diff --git a/roles/web-app-mailu/templates/env.j2 b/roles/web-app-mailu/templates/env.j2 index 4380bf41..079c9b1b 100644 --- a/roles/web-app-mailu/templates/env.j2 +++ b/roles/web-app-mailu/templates/env.j2 @@ -11,13 +11,13 @@ LD_PRELOAD=/usr/lib/libhardened_malloc.so # Set to a randomly generated 16 bytes string -SECRET_KEY={{applications.mailu.credentials.secret_key}} +SECRET_KEY={{applications | get_app_conf(application_id,'credentials.secret_key')}} # Subnet of the docker network. This should not conflict with any networks to which your system is connected. (Internal and external!) -SUBNET={{networks.local.mailu.subnet}} +SUBNET={{networks.local['web-app-mailu'].subnet}} # Main mail domain -DOMAIN={{applications.mailu.domain}} +DOMAIN={{ applications | get_app_conf(application_id,'domain') }} # Hostnames for this server, separated with comas HOSTNAMES={{domains | get_domain(application_id)}} @@ -151,7 +151,7 @@ SQLALCHEMY_DATABASE_URI=mysql+mysqlconnector://{{database_username}}:{{database_ API=true WEB_API=/api # Configures the authentication token. The minimum length is 3 characters. This token must be passed as request header to the API as authentication token. This is a mandatory setting for using the RESTful API. -API_TOKEN={{applications.mailu.credentials.api_token}} +API_TOKEN={{ applications | get_app_conf(application_id, 'credentials.api_token')}} # Activated https://mailu.io/master/configuration.html#advanced-settings diff --git a/roles/web-app-mailu/vars/main.yml b/roles/web-app-mailu/vars/main.yml index 3d9a6ce0..91a033ef 100644 --- a/roles/web-app-mailu/vars/main.yml +++ b/roles/web-app-mailu/vars/main.yml @@ -1,7 +1,7 @@ -application_id: "mailu" +application_id: "web-app-mailu" # Database Configuration -database_password: "{{applications.mailu.credentials.database_password}}" +database_password: "{{ applications | get_app_conf(application_id, ' credentials.database_password') }}" database_type: "mariadb" cert_mount_directory: "{{docker_compose.directories.volumes}}certs/" @@ -12,4 +12,14 @@ docker_source: "{{ 'ghcr.io/heviat' if applications | get_app_conf(a domain: "{{ domains | get_domain(application_id) }}" http_port: "{{ ports.localhost.http[application_id] }}" -proxy_extra_configuration: "client_max_body_size 31M;" \ No newline at end of file +proxy_extra_configuration: "client_max_body_size 31M;" +mailu_version: "{{ applications | get_app_conf(application_id, 'docker.services.mailu.version', True) }}" +mailu_name: "{{ applications | get_app_conf(application_id, 'docker.services.mailu.name', True) }}" +mailu_smtp_queue: "mailu_smtp_queue" +mailu_admin_data: "mailu_admin_data" +mailu_webdav_data: "mailu_webdav_data" +mailu_webmail_data: "mailu_webmail_data" +mailu_filter: "mailu_filter" +mailu_dkim: "mailu_dkim" +mailu_dovecot_mail: "mailu_dovecot_mail" +mailu_redis: "mailu_redis" diff --git a/roles/web-app-matomo/tasks/constructor.yml b/roles/web-app-matomo/tasks/constructor.yml index f046cbd2..974600c5 100644 --- a/roles/web-app-matomo/tasks/constructor.yml +++ b/roles/web-app-matomo/tasks/constructor.yml @@ -2,7 +2,7 @@ include_role: name: cmp-db-docker-proxy -- name: "Update database credentials" +- name: "Patch Matomo config.ini.php with updated DB credentials" include_tasks: database.yml - name: flush docker service diff --git a/roles/web-app-matomo/tasks/database.yml b/roles/web-app-matomo/tasks/database.yml index cac3f167..670b3035 100644 --- a/roles/web-app-matomo/tasks/database.yml +++ b/roles/web-app-matomo/tasks/database.yml @@ -1,25 +1,16 @@ -- name: Backup config.ini.php before patching +- name: Update DB host command: > - docker cp {{ matomo_name }}:{{ matomo_config }} {{ matomo_backup_file }} - -- name: Patch Matomo config.ini.php with updated DB credentials - block: - - name: Update DB host - command: > - docker exec --user root {{ matomo_name }} - sed -i "s/^host *=.*/host = {{ database_host }}/" {{ matomo_config }} - - - name: Update DB name - command: > - docker exec --user root {{ matomo_name }} - sed -i "s/^dbname *=.*/dbname = {{ database_name }}/" {{ matomo_config }} - - - name: Update DB user - command: > - docker exec --user root {{ matomo_name }} - sed -i "s/^username *=.*/username = {{ database_username }}/" {{ matomo_config }} - - - name: Update DB password - command: > - docker exec --user root {{ matomo_name }} - sed -i "s/^password *=.*/password = {{ database_password }}/" {{ matomo_config }} + docker exec --user root {{ matomo_name }} + sed -i "s/^host *=.*/host = {{ database_host }}/" {{ matomo_config }} +- name: Update DB name + command: > + docker exec --user root {{ matomo_name }} + sed -i "s/^dbname *=.*/dbname = {{ database_name }}/" {{ matomo_config }} +- name: Update DB user + command: > + docker exec --user root {{ matomo_name }} + sed -i "s/^username *=.*/username = {{ database_username }}/" {{ matomo_config }} +- name: Update DB password + command: > + docker exec --user root {{ matomo_name }} + sed -i "s/^password *=.*/password = {{ database_password }}/" {{ matomo_config }} diff --git a/roles/web-app-matomo/vars/main.yml b/roles/web-app-matomo/vars/main.yml index 8082df5a..4aec018b 100644 --- a/roles/web-app-matomo/vars/main.yml +++ b/roles/web-app-matomo/vars/main.yml @@ -8,7 +8,6 @@ matomo_version: "{{ applications | get_app_conf(application_id, 'docker.se matomo_image: "{{ applications | get_app_conf(application_id, 'docker.services.matomo.image', True) }}" matomo_name: "{{ applications | get_app_conf(application_id, 'docker.services.matomo.name', True) }}" matomo_data: "{{ applications | get_app_conf(application_id, 'docker.volumes.data', True) }}" -matomo_backup_file: "{{ docker_compose.directories.instance }}/config.ini.php.bak" matomo_config: "/var/www/html/config/config.ini.php" # I don't know if this is still necessary diff --git a/roles/web-app-moodle/tasks/database.yml b/roles/web-app-moodle/tasks/database.yml index 6a03b314..1c46fbd5 100644 --- a/roles/web-app-moodle/tasks/database.yml +++ b/roles/web-app-moodle/tasks/database.yml @@ -13,10 +13,6 @@ state: directory mode: "0755" - - name: Copy config.php from container to host - command: > - docker cp {{ moodle_container }}:{{ moodle_config }} {{ moodle_backup_file }} - - name: Check if config.php exists command: docker exec --user root {{ moodle_container }} test -f {{ moodle_config }} register: config_file_exists diff --git a/roles/web-app-moodle/vars/main.yml b/roles/web-app-moodle/vars/main.yml index e1519859..494bdc6e 100644 --- a/roles/web-app-moodle/vars/main.yml +++ b/roles/web-app-moodle/vars/main.yml @@ -10,7 +10,6 @@ bitnami_user_group: "{{ bitnami_user }}:{{ bitnami_user }}" docker_compose_flush_handlers: false # Wait for env update -moodle_backup_file: "{{ docker_compose.directories.instance }}/config.ini.php.bak" moodle_config: "/bitnami/moodle/config.php" moodle_version: "{{ applications | get_app_conf(application_id, 'docker.services.moodle.version', True) }}" moodle_image: "{{ applications | get_app_conf(application_id, 'docker.services.moodle.image', True) }}" diff --git a/roles/web-app-nextcloud/config/main.yml b/roles/web-app-nextcloud/config/main.yml index c9fb0c82..25ec9355 100644 --- a/roles/web-app-nextcloud/config/main.yml +++ b/roles/web-app-nextcloud/config/main.yml @@ -10,9 +10,12 @@ csp: - "data:" domains: canonical: - nextcloud: "cloud.{{ primary_domain }}" + - "cloud.{{ primary_domain }}" + # nextcloud: "cloud.{{ primary_domain }}" # talk: "talk.{{ primary_domain }}" @todo needs to be activated docker: + volumes: + data: nextcloud_data services: redis: enabled: true @@ -21,7 +24,7 @@ docker: nextcloud: name: "nextcloud" image: "nextcloud" - version: "latest-fpm-alpine" + version: "production-fpm-alpine" backup: no_stop_required: true proxy: diff --git a/roles/web-app-nextcloud/tasks/01_config.yml b/roles/web-app-nextcloud/tasks/01_config.yml index 6f862576..e0f26681 100644 --- a/roles/web-app-nextcloud/tasks/01_config.yml +++ b/roles/web-app-nextcloud/tasks/01_config.yml @@ -3,16 +3,27 @@ - name: Add dynamic config merging from Jinja template template: src: include.php.j2 - dest: "{{nextcloud_host_include_instructions_file}}" + dest: "{{ nextcloud_host_include_instructions_file }}" notify: docker compose restart + - name: Flush handlers so Nextcloud container is restarted and ready + meta: flush_handlers + + - name: "Wait until Nextcloud is reachable on port {{ports.localhost.http[application_id]}}" + wait_for: + host: 127.0.0.1 + port: "{{ports.localhost.http[application_id]}}" + timeout: 120 + delay: 2 + state: started + - name: Copy include instructions to the container command: > - docker cp {{ nextcloud_host_include_instructions_file }} {{ nextcloud_name }}:{{nextcloud_docker_include_instructions_file}} + docker cp {{ nextcloud_host_include_instructions_file }} {{ nextcloud_container }}:{{ nextcloud_docker_include_instructions_file }} - name: Append generated config to config.php only if not present command: > - docker exec -u {{nextcloud_docker_user}} {{ nextcloud_name }} sh -c " + docker exec -u {{ nextcloud_docker_user }} {{ nextcloud_container }} sh -c " grep -q '{{ nextcloud_docker_config_additives_directory }}' {{ nextcloud_docker_config_file }} || - cat {{nextcloud_docker_include_instructions_file}} >> {{ nextcloud_docker_config_file }}" - notify: docker compose restart \ No newline at end of file + cat {{ nextcloud_docker_include_instructions_file }} >> {{ nextcloud_docker_config_file }}" + notify: docker compose restart diff --git a/roles/web-app-nextcloud/tasks/main.yml b/roles/web-app-nextcloud/tasks/main.yml index b25fc558..8b1124de 100644 --- a/roles/web-app-nextcloud/tasks/main.yml +++ b/roles/web-app-nextcloud/tasks/main.yml @@ -65,7 +65,7 @@ - name: Ensure Nextcloud administrator is in the 'admin' group command: > - docker exec -u {{ nextcloud_docker_user }} {{ nextcloud_name }} + docker exec -u {{ nextcloud_docker_user }} {{ nextcloud_container }} php occ group:adduser admin {{ nextcloud_administrator_username }} register: add_admin_to_group changed_when: "'Added user' in add_admin_to_group.stdout" diff --git a/roles/web-app-nextcloud/templates/docker-compose.yml.j2 b/roles/web-app-nextcloud/templates/docker-compose.yml.j2 index 32862dad..ab699986 100644 --- a/roles/web-app-nextcloud/templates/docker-compose.yml.j2 +++ b/roles/web-app-nextcloud/templates/docker-compose.yml.j2 @@ -2,7 +2,7 @@ application: image: "{{ nextcloud_image }}:{{ nextcloud_version }}" - container_name: {{ nextcloud_name }} + container_name: {{ nextcloud_container }} volumes: - data:{{nextcloud_docker_work_directory}} - {{nextcloud_host_config_additives_directory}}:{{nextcloud_docker_config_additives_directory}}:ro @@ -70,6 +70,7 @@ {% include 'roles/docker-compose/templates/volumes.yml.j2' %} data: + name: {{ nextcloud_volume }} redis: {% include 'roles/docker-compose/templates/networks.yml.j2' %} diff --git a/roles/web-app-nextcloud/vars/main.yml b/roles/web-app-nextcloud/vars/main.yml index 77421745..1e9296d8 100644 --- a/roles/web-app-nextcloud/vars/main.yml +++ b/roles/web-app-nextcloud/vars/main.yml @@ -20,16 +20,18 @@ nextcloud_control_node_plugin_tasks_directory: "{{role_path}}/tasks/plugins/" # Host ## Host Paths -nextcloud_host_config_additives_directory: "{{docker_compose.directories.volumes}}cymais/" # This folder is the path to which the additive configurations will be copied -nextcloud_host_include_instructions_file: "{{docker_compose.directories.volumes}}includes.php" # Path to the instruction file on the host. Responsible for loading the additional configurations +nextcloud_host_config_additives_directory: "{{ docker_compose.directories.volumes }}cymais/" # This folder is the path to which the additive configurations will be copied +nextcloud_host_include_instructions_file: "{{ docker_compose.directories.volumes }}includes.php" # Path to the instruction file on the host. Responsible for loading the additional configurations -nextcloud_domains: "{{ domains[application_id].nextcloud }}" +nextcloud_domains: "{{ domains | get_domain(application_id) }}" # This is wrong and should be optimized @todo implement support for multiple domains # Docker +nextcloud_volume: "{{ applications | get_app_conf(application_id, 'docker.volumes.data', True) }}" + nextcloud_version: "{{ applications | get_app_conf(application_id, 'docker.services.nextcloud.version', True) }}" nextcloud_image: "{{ applications | get_app_conf(application_id, 'docker.services.nextcloud.image', True) }}" -nextcloud_name: "{{ applications | get_app_conf(application_id, 'docker.services.nextcloud.name', True) }}" +nextcloud_container: "{{ applications | get_app_conf(application_id, 'docker.services.nextcloud.name', True) }}" nextcloud_proxy_name: "{{ applications | get_app_conf(application_id, 'docker.services.proxy.name', True) }}" nextcloud_proxy_image: "{{ applications | get_app_conf(application_id, 'docker.services.proxy.image', True) }}" @@ -58,5 +60,5 @@ nextcloud_docker_config_additives_directory: "{{nextcloud_docker_config_direc nextcloud_docker_include_instructions_file: "/tmp/includes.php" # Path to the temporary file which will be included to the config.php to load the additional configurations ## Execution -nextcloud_docker_exec: "docker exec -u {{ nextcloud_docker_user }} {{ nextcloud_name }}" # General execute composition +nextcloud_docker_exec: "docker exec -u {{ nextcloud_docker_user }} {{ nextcloud_container }}" # General execute composition nextcloud_docker_exec_occ: "{{nextcloud_docker_exec}} {{ nextcloud_docker_work_directory }}occ" # Execute docker occ command \ No newline at end of file diff --git a/roles/web-app-port-ui/templates/menu/followus.yml.j2 b/roles/web-app-port-ui/templates/menu/followus.yml.j2 index feafdb1d..e8db2121 100644 --- a/roles/web-app-port-ui/templates/menu/followus.yml.j2 +++ b/roles/web-app-port-ui/templates/menu/followus.yml.j2 @@ -3,7 +3,7 @@ followus: description: Follow us to stay up to recieve the newest CyMaIS updates icon: class: fas fa-newspaper -{% if ["mastodon", "bluesky"] | any_in(group_names) %} +{% if ["web-app-mastodon", "web-app-bluesky"] | any_in(group_names) %} children: {% if service_provider.contact.mastodon is defined and service_provider.contact.mastodon != "" %} - name: Mastodon diff --git a/roles/web-app-wordpress/tasks/main.yml b/roles/web-app-wordpress/tasks/main.yml index f10ab962..e89988fc 100644 --- a/roles/web-app-wordpress/tasks/main.yml +++ b/roles/web-app-wordpress/tasks/main.yml @@ -6,7 +6,7 @@ - name: "Include role srv-proxy-6-6-domain for {{ application_id }}" include_role: name: srv-proxy-6-6-domain - loop: "{{ applications | get_app_conf(application_id, 'domain', True)s.canonical }}" + loop: "{{ applications | get_app_conf(application_id, 'domains.canonical', True) }}" loop_control: loop_var: domain vars: diff --git a/tests/unit/filter_plugins/test_get_all_invokable_apps.py b/tests/unit/filter_plugins/test_get_all_invokable_apps.py new file mode 100644 index 00000000..7d32488b --- /dev/null +++ b/tests/unit/filter_plugins/test_get_all_invokable_apps.py @@ -0,0 +1,118 @@ +import os +import shutil +import tempfile +import yaml +import unittest + +from filter_plugins.get_all_invokable_apps import get_all_invokable_apps + +class TestGetAllInvokableApps(unittest.TestCase): + def setUp(self): + """Create a temporary roles/ directory with categories.yml and some example roles.""" + self.test_dir = tempfile.mkdtemp(prefix="invokable_apps_test_") + self.roles_dir = os.path.join(self.test_dir, "roles") + os.makedirs(self.roles_dir, exist_ok=True) + self.categories_file = os.path.join(self.roles_dir, "categories.yml") + + # Write a categories.yml with nested invokable/non-invokable paths + categories = { + "roles": { + "web": { + "title": "Web", + "invokable": False, + "app": { + "title": "Applications", + "invokable": True + }, + "svc": { + "title": "Services", + "invokable": False + } + }, + "update": { + "title": "Update", + "invokable": True + }, + "util": { + "title": "Utils", + "invokable": False, + "desk": { + "title": "Desktop Utils", + "invokable": True + } + } + } + } + with open(self.categories_file, 'w') as f: + yaml.safe_dump(categories, f) + + # Create roles: some should match invokable paths, some shouldn't + roles = [ + ('web-app-nextcloud', 'web-app-nextcloud'), + ('web-app-matomo', 'matomo-app'), # application_id differs + ('web-svc-nginx', None), # should NOT match any invokable path + ('update', None), # exact match to invokable path + ('util-desk-custom', None) # matches util-desk + ] + for rolename, appid in roles: + role_dir = os.path.join(self.roles_dir, rolename) + os.makedirs(os.path.join(role_dir, 'vars'), exist_ok=True) + vars_path = os.path.join(role_dir, 'vars', 'main.yml') + data = {} + if appid: + data['application_id'] = appid + with open(vars_path, 'w') as f: + yaml.safe_dump(data, f) + + def tearDown(self): + """Clean up the temporary test directory after each test.""" + shutil.rmtree(self.test_dir) + + def test_get_all_invokable_apps(self): + """Should return only applications whose role paths match invokable paths.""" + result = get_all_invokable_apps( + categories_file=self.categories_file, + roles_dir=self.roles_dir + ) + expected = sorted([ + 'web-app-nextcloud', # application_id from role + 'matomo-app', # application_id from role + 'update', # role directory name + 'util-desk-custom' # role directory name + ]) + self.assertEqual(sorted(result), expected) + + def test_empty_when_no_invokable(self): + """Should return an empty list if there are no invokable paths in categories.yml.""" + with open(self.categories_file, 'w') as f: + yaml.safe_dump({"roles": {"foo": {"invokable": False}}}, f) + result = get_all_invokable_apps( + categories_file=self.categories_file, + roles_dir=self.roles_dir + ) + self.assertEqual(result, []) + + def test_empty_when_no_roles(self): + """Should return an empty list if there are no roles, but categories.yml exists.""" + shutil.rmtree(self.roles_dir) + os.makedirs(self.roles_dir, exist_ok=True) + # Recreate categories.yml after removing roles_dir + with open(self.categories_file, 'w') as f: + yaml.safe_dump({"roles": {"web": {"app": {"invokable": True}}}}, f) + result = get_all_invokable_apps( + categories_file=self.categories_file, + roles_dir=self.roles_dir + ) + self.assertEqual(result, []) + + def test_error_when_no_categories_file(self): + """Should raise FileNotFoundError if categories.yml is missing.""" + os.remove(self.categories_file) + with self.assertRaises(FileNotFoundError): + get_all_invokable_apps( + categories_file=self.categories_file, + roles_dir=self.roles_dir + ) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/filter_plugins/test_get_entity_name.py b/tests/unit/filter_plugins/test_get_entity_name.py new file mode 100644 index 00000000..5d9ee0d2 --- /dev/null +++ b/tests/unit/filter_plugins/test_get_entity_name.py @@ -0,0 +1,93 @@ +import unittest +import tempfile +import shutil +import os +import sys +import yaml + +class TestGetEntityNameFilter(unittest.TestCase): + def setUp(self): + # Create a temporary directory for roles and categories.yml + self.temp_dir = tempfile.mkdtemp() + self.roles_dir = os.path.join(self.temp_dir, 'roles') + os.makedirs(self.roles_dir) + self.categories_file = os.path.join(self.roles_dir, 'categories.yml') + + # Minimal categories.yml for tests + categories = { + 'roles': { + 'web': { + 'app': { + 'title': "Applications", + 'invokable': True + }, + 'svc': { + 'title': "Services", + 'invokable': True + } + }, + 'util': { + 'desk': { + 'dev': { + 'title': "Dev Utilities", + 'invokable': True + } + } + }, + 'sys': { + 'bkp': { + 'title': "Backup", + 'invokable': True + }, + 'hlth': { + 'title': "Health", + 'invokable': True + } + } + } + } + with open(self.categories_file, 'w', encoding='utf-8') as f: + yaml.safe_dump(categories, f, default_flow_style=False) + + # Patch working directory so plugin finds the test categories.yml + self._cwd = os.getcwd() + os.chdir(self.temp_dir) + + # Make sure filter_plugins directory is on sys.path + plugin_path = os.path.join(self._cwd, "filter_plugins") + if plugin_path not in sys.path and os.path.isdir(plugin_path): + sys.path.insert(0, plugin_path) + # Import plugin fresh each time + global get_entity_name + from filter_plugins.get_entity_name import get_entity_name + + self.get_entity_name = get_entity_name + + def tearDown(self): + os.chdir(self._cwd) + shutil.rmtree(self.temp_dir) + + def test_entity_name_web_app(self): + self.assertEqual(self.get_entity_name("web-app-snipe-it"), "snipe-it") + self.assertEqual(self.get_entity_name("web-app-nextcloud"), "nextcloud") + self.assertEqual(self.get_entity_name("web-svc-file"), "file") + + def test_entity_name_util_desk_dev(self): + self.assertEqual(self.get_entity_name("util-desk-dev-arduino"), "arduino") + self.assertEqual(self.get_entity_name("util-desk-dev-shell"), "shell") + + def test_entity_name_sys_bkp(self): + self.assertEqual(self.get_entity_name("sys-bkp-directory-validator"), "directory-validator") + + def test_entity_name_sys_hlth(self): + self.assertEqual(self.get_entity_name("sys-hlth-btrfs"), "btrfs") + + def test_no_category_match(self): + # Unknown category, should return input + self.assertEqual(self.get_entity_name("foobar-role"), "foobar-role") + + def test_exact_category_match(self): + self.assertEqual(self.get_entity_name("web-app"), "") + +if __name__ == "__main__": + unittest.main()