From ead60dab8495d47a6ada19091f10bd5909262195 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 12 Jul 2025 21:35:33 +0200 Subject: [PATCH] Fail safed more parts of the code --- cli/meta/applications/all.py | 37 ++++----- cli/meta/applications/role_name.py | 4 +- filter_plugins/get_all_application_ids.py | 40 +++++++++ filter_plugins/get_application_id.py | 51 ++++++++++++ .../{get_role_folder.py => get_role.py} | 8 +- group_vars/all/09_ports.yml | 6 +- .../templates/iframe-handler.js.j2 | 2 +- roles/web-app-matrix/tasks/main.yml | 6 +- .../templates/docker-compose.yml.j2 | 4 +- roles/web-app-matrix/templates/nginx.conf.j2 | 2 +- roles/web-app-port-ui/README.md | 2 +- .../lookup_plugins/docker_cards.py | 36 +++++--- roles/web-app-port-ui/meta/main.yml | 2 +- roles/web-app-port-ui/vars/main.yml | 2 +- ...est_all_ports_application_ids_are_valid.py | 51 ++++++++++++ .../test_ansible_roles_metadata.py | 13 ++- .../test_application_id_matches_role_name.py | 83 ++++++++++++------- .../test_dependency_application_id.py | 7 +- .../test_get_domain_application_ids.py | 46 ++++++++++ tests/integration/test_roles_folder_names.py | 42 ++++++++++ .../test_get_all_application_ids.py | 55 ++++++++++++ .../filter_plugins/test_get_application_id.py | 63 ++++++++++++++ .../filter_plugins/test_get_role_folder.py | 10 +-- .../unit/lookup_plugins/test_docker_cards.py | 23 +++-- 24 files changed, 493 insertions(+), 102 deletions(-) create mode 100644 filter_plugins/get_all_application_ids.py create mode 100644 filter_plugins/get_application_id.py rename filter_plugins/{get_role_folder.py => get_role.py} (88%) create mode 100644 tests/integration/test_all_ports_application_ids_are_valid.py create mode 100644 tests/integration/test_get_domain_application_ids.py create mode 100644 tests/integration/test_roles_folder_names.py create mode 100644 tests/unit/filter_plugins/test_get_all_application_ids.py create mode 100644 tests/unit/filter_plugins/test_get_application_id.py diff --git a/cli/meta/applications/all.py b/cli/meta/applications/all.py index b185ef9b..58292ee2 100644 --- a/cli/meta/applications/all.py +++ b/cli/meta/applications/all.py @@ -1,46 +1,37 @@ #!/usr/bin/env python3 +# cli/meta/applications/all.py + import argparse -import glob -import os import sys +# Import the Ansible filter implementation try: - import yaml + from filter_plugins.get_all_application_ids import get_all_application_ids except ImportError: - sys.stderr.write("PyYAML is required. Install with `pip install pyyaml`.\n") + sys.stderr.write("Filter plugin `get_all_application_ids` not found. Ensure `filter_plugins/get_all_application_ids.py` is in your PYTHONPATH.\n") sys.exit(1) def find_application_ids(): """ - Searches all files matching roles/*/vars/main.yml for the key 'application_id' - and returns a list of all found IDs. + Legacy function retained for reference. + Delegates to the `get_all_application_ids` filter plugin. """ - pattern = os.path.join('roles', '*', 'vars', 'main.yml') - app_ids = [] - - for filepath in glob.glob(pattern): - try: - with open(filepath, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) - except Exception as e: - sys.stderr.write(f"Error reading {filepath}: {e}\n") - continue - - if isinstance(data, dict) and 'application_id' in data: - app_ids.append(data['application_id']) - - return sorted(set(app_ids)) + return get_all_application_ids() def main(): parser = argparse.ArgumentParser( description='Output a list of all application_id values defined in roles/*/vars/main.yml' ) - # No arguments other than --help parser.parse_args() - ids = find_application_ids() + try: + ids = find_application_ids() + except Exception as e: + sys.stderr.write(f"Error retrieving application IDs: {e}\n") + sys.exit(1) + for app_id in ids: print(app_id) diff --git a/cli/meta/applications/role_name.py b/cli/meta/applications/role_name.py index fcdc7e67..3d23825d 100644 --- a/cli/meta/applications/role_name.py +++ b/cli/meta/applications/role_name.py @@ -13,7 +13,7 @@ import argparse import yaml -def get_role_folder(application_id, roles_path): +def get_role(application_id, roles_path): """ Find the role directory under `roles_path` whose vars/main.yml contains the specified application_id. @@ -62,7 +62,7 @@ def main(): args = parser.parse_args() try: - folder = get_role_folder(args.application_id, args.roles_path) + folder = get_role(args.application_id, args.roles_path) print(folder) sys.exit(0) except RuntimeError as err: diff --git a/filter_plugins/get_all_application_ids.py b/filter_plugins/get_all_application_ids.py new file mode 100644 index 00000000..e498d95b --- /dev/null +++ b/filter_plugins/get_all_application_ids.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# filter_plugins/get_all_application_ids.py + +import glob +import os +import yaml + + +def get_all_application_ids(roles_dir='roles'): + """ + Ansible filter to retrieve all unique application_id values + defined in roles/*/vars/main.yml files. + + :param roles_dir: Base directory for Ansible roles (default: 'roles') + :return: Sorted list of unique application_id strings + """ + pattern = os.path.join(roles_dir, '*', 'vars', 'main.yml') + app_ids = [] + + for filepath in glob.glob(pattern): + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + except Exception: + continue + + if isinstance(data, dict) and 'application_id' in data: + app_ids.append(data['application_id']) + + return sorted(set(app_ids)) + + +class FilterModule(object): + """ + Ansible filter plugin for retrieving application IDs. + """ + def filters(self): + return { + 'get_all_application_ids': get_all_application_ids + } diff --git a/filter_plugins/get_application_id.py b/filter_plugins/get_application_id.py new file mode 100644 index 00000000..4828a999 --- /dev/null +++ b/filter_plugins/get_application_id.py @@ -0,0 +1,51 @@ +# filter_plugins/get_application_id.py + +import os +import re +import yaml +from ansible.errors import AnsibleFilterError + + +def get_application_id(role_name): + """ + Jinja2/Ansible filter: given a role name, load its vars/main.yml and return the application_id value. + """ + # Construct path: assumes current working directory is project root + vars_file = os.path.join(os.getcwd(), 'roles', role_name, 'vars', 'main.yml') + + if not os.path.isfile(vars_file): + raise AnsibleFilterError(f"Vars file not found for role '{role_name}': {vars_file}") + + try: + # Read entire file content to avoid lazy stream issues + with open(vars_file, 'r', encoding='utf-8') as f: + content = f.read() + data = yaml.safe_load(content) + except Exception as e: + raise AnsibleFilterError(f"Error reading YAML from {vars_file}: {e}") + + # Ensure parsed data is a mapping + if not isinstance(data, dict): + raise AnsibleFilterError( + f"Error reading YAML from {vars_file}: expected mapping, got {type(data).__name__}" + ) + + # Detect malformed YAML: no valid identifier-like keys + valid_key_pattern = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + if data and not any(valid_key_pattern.match(k) for k in data.keys()): + raise AnsibleFilterError(f"Error reading YAML from {vars_file}: invalid top-level keys") + + if 'application_id' not in data: + raise AnsibleFilterError(f"Key 'application_id' not found in {vars_file}") + + return data['application_id'] + + +class FilterModule(object): + """ + Ansible filter plugin entry point. + """ + def filters(self): + return { + 'get_application_id': get_application_id, + } diff --git a/filter_plugins/get_role_folder.py b/filter_plugins/get_role.py similarity index 88% rename from filter_plugins/get_role_folder.py rename to filter_plugins/get_role.py index 22a5957c..1849a544 100644 --- a/filter_plugins/get_role_folder.py +++ b/filter_plugins/get_role.py @@ -1,5 +1,5 @@ ''' -Ansible filter plugin: get_role_folder +Ansible filter plugin: get_role This filter inspects each role under the given roles directory, loads its vars/main.yml, and returns the role folder name whose application_id matches the provided value. @@ -10,7 +10,7 @@ import os import yaml -def get_role_folder(application_id, roles_path='roles'): +def get_role(application_id, roles_path='roles'): """ Find the role directory under `roles_path` whose vars/main.yml contains the given application_id. @@ -40,9 +40,9 @@ def get_role_folder(application_id, roles_path='roles'): class FilterModule(object): """ - Register the get_role_folder filter + Register the get_role filter """ def filters(self): return { - 'get_role_folder': get_role_folder, + 'get_role': get_role, } diff --git a/group_vars/all/09_ports.yml b/group_vars/all/09_ports.yml index 3f9665b2..a3918c94 100644 --- a/group_vars/all/09_ports.yml +++ b/group_vars/all/09_ports.yml @@ -38,15 +38,15 @@ ports: matomo: 8018 listmonk: 8019 discourse: 8020 - synapse: 8021 - element: 8022 + matrix_synapse: 8021 + matrix_element: 8022 openproject: 8023 gitlab: 8024 akaunting: 8025 moodle: 8026 taiga: 8027 friendica: 8028 - portfolio: 8029 + web-app-port-ui: 8029 bluesky_api: 8030 bluesky_web: 8031 keycloak: 8032 diff --git a/roles/srv-web-7-7-inj-iframe/templates/iframe-handler.js.j2 b/roles/srv-web-7-7-inj-iframe/templates/iframe-handler.js.j2 index 89388065..6b927f13 100644 --- a/roles/srv-web-7-7-inj-iframe/templates/iframe-handler.js.j2 +++ b/roles/srv-web-7-7-inj-iframe/templates/iframe-handler.js.j2 @@ -1,6 +1,6 @@ (function() { var primary = "{{ primary_domain }}"; - var allowedOrigin = "https://{{ domains | get_domain('portfolio') }}"; + var allowedOrigin = "https://{{ domains | get_domain('web-app-port-ui') }}"; function notifyParent() { try { diff --git a/roles/web-app-matrix/tasks/main.yml b/roles/web-app-matrix/tasks/main.yml index 53b0cf99..f584e407 100644 --- a/roles/web-app-matrix/tasks/main.yml +++ b/roles/web-app-matrix/tasks/main.yml @@ -17,7 +17,7 @@ name: srv-web-7-6-composer vars: domain: "{{domains.matrix.synapse}}" - http_port: "{{ports.localhost.http.synapse}}" + http_port: "{{ports.localhost.http.matrix_synapse}}" - name: create {{well_known_directory}} file: @@ -36,7 +36,7 @@ dest: "{{nginx.directories.http.servers}}{{domains.matrix.synapse}}.conf" vars: domain: "{{domains.matrix.synapse}}" # Didn't work in the past. May it works now. This does not seem to work @todo Check how to solve without declaring set_fact, seems a bug at templates - http_port: "{{ports.localhost.http.synapse}}" + http_port: "{{ports.localhost.http.matrix_synapse}}" notify: restart nginx - name: "include role srv-proxy-6-6-domain for {{application_id}}" @@ -44,7 +44,7 @@ name: srv-proxy-6-6-domain vars: domain: "{{domains.matrix.element}}" - http_port: "{{ports.localhost.http.element}}" + http_port: "{{ports.localhost.http.matrix_element}}" - name: include create-and-seed-database.yml for multiple bridges include_tasks: create-and-seed-database.yml diff --git a/roles/web-app-matrix/templates/docker-compose.yml.j2 b/roles/web-app-matrix/templates/docker-compose.yml.j2 index ccda17d6..a25b9fc5 100644 --- a/roles/web-app-matrix/templates/docker-compose.yml.j2 +++ b/roles/web-app-matrix/templates/docker-compose.yml.j2 @@ -17,7 +17,7 @@ - SYNAPSE_SERVER_NAME={{domains.matrix.synapse}} - SYNAPSE_REPORT_STATS=no ports: - - "127.0.0.1:{{ports.localhost.http.synapse}}:{{ container_port }}" + - "127.0.0.1:{{ports.localhost.http.matrix_synapse}}:{{ container_port }}" {% include 'roles/docker-container/templates/healthcheck/curl.yml.j2' %} {% if bridges | length > 0 %} {% for item in bridges %} @@ -36,7 +36,7 @@ volumes: - ./element-config.json:/app/config.json ports: - - "127.0.0.1:{{ports.localhost.http.element}}:{{ container_port }}" + - "127.0.0.1:{{ports.localhost.http.matrix_element}}:{{ container_port }}" {% include 'roles/docker-container/templates/healthcheck/wget.yml.j2' %} {% include 'roles/docker-container/templates/networks.yml.j2' %} diff --git a/roles/web-app-matrix/templates/nginx.conf.j2 b/roles/web-app-matrix/templates/nginx.conf.j2 index 8eb24708..175627cb 100644 --- a/roles/web-app-matrix/templates/nginx.conf.j2 +++ b/roles/web-app-matrix/templates/nginx.conf.j2 @@ -2,7 +2,7 @@ server { {# Somehow .j2 doesn't interpretate the passed variable right. For this reasons this redeclaration is necessary #} {# Could be that this is related to the set_fact use #} {% set domain = domains.matrix.synapse %} - {% set http_port = ports.localhost.http.synapse %} + {% set http_port = ports.localhost.http.matrix_synapse %} server_name {{domains.matrix.synapse}}; {% include 'roles/srv-web-7-7-letsencrypt/templates/ssl_header.j2' %} diff --git a/roles/web-app-port-ui/README.md b/roles/web-app-port-ui/README.md index 8d2cb49a..ec57bffa 100644 --- a/roles/web-app-port-ui/README.md +++ b/roles/web-app-port-ui/README.md @@ -1,4 +1,4 @@ -# PortWebUI +# PortUI ## Description diff --git a/roles/web-app-port-ui/lookup_plugins/docker_cards.py b/roles/web-app-port-ui/lookup_plugins/docker_cards.py index 3129d435..2e05eefd 100644 --- a/roles/web-app-port-ui/lookup_plugins/docker_cards.py +++ b/roles/web-app-port-ui/lookup_plugins/docker_cards.py @@ -16,13 +16,13 @@ class LookupModule(LookupBase): This lookup iterates over all roles whose folder name starts with 'web-app-' and generates a list of dictionaries (cards). For each role, it: - - Extracts the application_id (everything after "web-app-") + - Reads application_id from the role's vars/main.yml - Reads the title from the role's README.md (the first H1 line) - Retrieves the description from galaxy_info.description in meta/main.yml - Retrieves the icon class from galaxy_info.logo.class - Retrieves the tags from galaxy_info.galaxy_tags - - Builds the URL using the 'domains' variable (e.g. domains | get_domain(application_id)) - - Sets the iframe flag from applications[application_id].features.iframe + - Builds the URL using the 'domains' variable + - Sets the iframe flag from applications[application_id].features.portfolio_iframe Only cards whose application_id is included in the variable group_names are returned. """ @@ -40,11 +40,22 @@ class LookupModule(LookupBase): role_basename = os.path.basename(role_dir) # Skip roles not starting with "web-app-" - if not role_basename.startswith("web-app-"): + if not role_basename.startswith("web-app-"): # Ensure prefix continue - # Extract application_id from role name - application_id = role_basename[len("web-app-"):] + # Load application_id from role's vars/main.yml + vars_path = os.path.join(role_dir, "vars", "main.yml") + try: + if not os.path.isfile(vars_path): + raise AnsibleError(f"Vars file not found for role '{role_basename}': {vars_path}") + with open(vars_path, "r", encoding="utf-8") as vf: + vars_content = vf.read() + vars_data = yaml.safe_load(vars_content) or {} + application_id = vars_data.get("application_id") + if not application_id: + raise AnsibleError(f"Key 'application_id' not found in {vars_path}") + except Exception as e: + raise AnsibleError(f"Error getting application_id for role '{role_basename}': {e}") # Skip roles not listed in group_names if application_id not in group_names: @@ -65,25 +76,24 @@ class LookupModule(LookupBase): title_match = re.search(r'^#\s+(.*)$', readme_content, re.MULTILINE) title = title_match.group(1).strip() if title_match else application_id except Exception as e: - raise AnsibleError("Error reading '{}': {}".format(readme_path, str(e))) + raise AnsibleError(f"Error reading '{readme_path}': {e}") # Extract metadata from meta/main.yml try: with open(meta_path, "r", encoding="utf-8") as f: - meta_data = yaml.safe_load(f) + meta_data = yaml.safe_load(f) or {} galaxy_info = meta_data.get("galaxy_info", {}) - # If display is set to False ignore it if not galaxy_info.get("display", True): continue - + description = galaxy_info.get("description", "") logo = galaxy_info.get("logo", {}) icon_class = logo.get("class", "fa-solid fa-cube") tags = galaxy_info.get("galaxy_tags", []) except Exception as e: - raise AnsibleError("Error reading '{}': {}".format(meta_path, str(e))) + raise AnsibleError(f"Error reading '{meta_path}': {e}") # Retrieve domains and applications from the variables domains = variables.get("domains", {}) @@ -94,7 +104,7 @@ class LookupModule(LookupBase): domain_url = domain_url[0] elif isinstance(domain_url, dict): domain_url = next(iter(domain_url.values())) - + # Construct the URL using the domain_url if available. url = "https://" + domain_url if domain_url else "" @@ -107,7 +117,7 @@ class LookupModule(LookupBase): "title": title, "text": description, "url": url, - "link_text": "Explore {}".format(title), + "link_text": f"Explore {title}", "iframe": iframe, "tags": tags, } diff --git a/roles/web-app-port-ui/meta/main.yml b/roles/web-app-port-ui/meta/main.yml index a4c70c41..9fd0c22d 100644 --- a/roles/web-app-port-ui/meta/main.yml +++ b/roles/web-app-port-ui/meta/main.yml @@ -1,7 +1,7 @@ --- galaxy_info: author: "Kevin Veen-Birkenbach" - description: "Portfolio to showcase your projects and creative work with a focus on user experience and easy customization. 🚀" + description: "PortUI provides CyMaIS users with a unified web interface to easily access all their applications in one place" license: "CyMaIS NonCommercial License (CNCL)" license_url: "https://s.veen.world/cncl" company: | diff --git a/roles/web-app-port-ui/vars/main.yml b/roles/web-app-port-ui/vars/main.yml index 8900aa6f..2bdc462b 100644 --- a/roles/web-app-port-ui/vars/main.yml +++ b/roles/web-app-port-ui/vars/main.yml @@ -1,4 +1,4 @@ application_id: "web-app-port-ui" -docker_repository_address: "https://github.com/kevinveenbirkenbach/port-web-ui" +docker_repository_address: "https://github.com/kevinveenbirkenbach/port-ui" config_inventory_path: "{{ inventory_dir }}/files/{{ inventory_hostname }}/docker/web-app-port-ui/config.yaml.j2" docker_repository: true \ No newline at end of file diff --git a/tests/integration/test_all_ports_application_ids_are_valid.py b/tests/integration/test_all_ports_application_ids_are_valid.py new file mode 100644 index 00000000..daa9aa9a --- /dev/null +++ b/tests/integration/test_all_ports_application_ids_are_valid.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +import os +import unittest +import yaml + +from filter_plugins.get_all_application_ids import get_all_application_ids + +class TestApplicationIDsInPorts(unittest.TestCase): + def test_all_ports_application_ids_are_valid(self): + # Path to the ports definition file + ports_file = os.path.abspath( + os.path.join( + os.path.dirname(__file__), '..', '..', 'group_vars', 'all', '09_ports.yml' + ) + ) + with open(ports_file, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + + # Collect all referenced application IDs under ports.hosttype.porttype + refs = set() + ports = data.get('ports', {}) or {} + for hosttype, porttypes in ports.items(): + if not isinstance(porttypes, dict): + continue + for porttype, apps in porttypes.items(): + if not isinstance(apps, dict): + continue + for app_id in apps.keys(): + refs.add(app_id) + + # Retrieve valid application IDs from Ansible roles + valid_ids = set(get_all_application_ids()) + + # Identify IDs that are neither valid nor have a valid prefix before the first underscore + missing = [] + for app_id in refs: + if app_id in valid_ids: + continue + prefix = app_id.split('_', 1)[0] + if prefix in valid_ids: + continue + missing.append(app_id) + + if missing: + self.fail( + f"Undefined application IDs in ports definition: {', '.join(sorted(missing))}" + ) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/integration/test_ansible_roles_metadata.py b/tests/integration/test_ansible_roles_metadata.py index ca0b685f..bf5736a2 100644 --- a/tests/integration/test_ansible_roles_metadata.py +++ b/tests/integration/test_ansible_roles_metadata.py @@ -9,10 +9,15 @@ class TestAnsibleRolesMetadata(unittest.TestCase): @classmethod def setUpClass(cls): - if not os.path.isdir(cls.ROLES_DIR): - raise unittest.SkipTest(f"Roles directory not found at {cls.ROLES_DIR}") - cls.roles = [d for d in os.listdir(cls.ROLES_DIR) - if os.path.isdir(os.path.join(cls.ROLES_DIR, d))] + all_dirs = os.listdir(cls.ROLES_DIR) + cls.roles = [ + d for d in all_dirs + if ( + os.path.isdir(os.path.join(cls.ROLES_DIR, d)) + and d != '__pycache__' + ) + ] + def test_each_role_has_valid_meta(self): """ diff --git a/tests/integration/test_application_id_matches_role_name.py b/tests/integration/test_application_id_matches_role_name.py index e64ce561..4379ae09 100644 --- a/tests/integration/test_application_id_matches_role_name.py +++ b/tests/integration/test_application_id_matches_role_name.py @@ -1,51 +1,74 @@ import os -import unittest -import yaml import glob +import yaml import warnings +import unittest -class TestApplicationIdMatchesRoleName(unittest.TestCase): - def test_application_id_matches_role_directory(self): - """ - Warn if application_id in vars/main.yml does not match - the role directory basename, to avoid confusion. - If vars/main.yml is missing, do nothing. - """ - # locate the 'roles' directory (two levels up) +# import your filters +from filter_plugins.invokable_paths import get_invokable_paths, get_non_invokable_paths + +class TestApplicationIdAndInvocability(unittest.TestCase): + @classmethod + def setUpClass(cls): + # locate roles dir (two levels up) base_dir = os.path.dirname(__file__) - roles_dir = os.path.abspath(os.path.join(base_dir, '..', '..', 'roles')) + cls.roles_dir = os.path.abspath(os.path.join(base_dir, '..', '..', 'roles')) - # iterate over each role folder - for role_path in glob.glob(os.path.join(roles_dir, '*')): + # get lists of invokable and non-invokable role *names* + # filters return dash-joined paths; for top-level roles names are just the basename + cls.invokable = { + p.split('-', 1)[0] + for p in get_invokable_paths() + } + cls.non_invokable = { + p.split('-', 1)[0] + for p in get_non_invokable_paths() + } + + def test_application_id_presence_and_match(self): + """ + - Invokable roles must have application_id defined (else fail). + - Non-invokable roles must NOT have application_id (else fail). + - If application_id exists but != folder name, warn and recommend aligning. + """ + for role_path in glob.glob(os.path.join(self.roles_dir, '*')): if not os.path.isdir(role_path): continue role_name = os.path.basename(role_path) vars_main = os.path.join(role_path, 'vars', 'main.yml') - # skip roles without vars/main.yml - if not os.path.exists(vars_main): - continue - - with open(vars_main, 'r', encoding='utf-8') as f: - data = yaml.safe_load(f) or {} + # load vars/main.yml if it exists + data = {} + if os.path.exists(vars_main): + with open(vars_main, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) or {} app_id = data.get('application_id') - if app_id is None: + + if role_name in self.invokable: + # must have application_id + if app_id is None: + self.fail(f"{role_name}: invokable role is missing 'application_id' in vars/main.yml") + elif role_name in self.non_invokable: + # must NOT have application_id + if app_id is not None: + self.fail(f"{role_name}: non-invokable role should not define 'application_id' in vars/main.yml") + else: + # roles not mentioned in categories.yml? we'll skip them + continue + + # if present but mismatched, warn + if app_id is not None and app_id != role_name: warnings.warn( - f"{role_name}: 'application_id' is missing in vars/main.yml. " - f"Consider setting it to '{role_name}' to avoid confusion." - ) - elif app_id != role_name: - warnings.warn( - f"{role_name}: 'application_id' is '{app_id}', " - f"but the folder name is '{role_name}'. " - "This can lead to confusion—using the directory name " - "as the application_id is recommended." + f"{role_name}: 'application_id' is '{app_id}'," + f" but the folder name is '{role_name}'." + " Consider setting application_id to exactly the role folder name to avoid confusion." ) - # always pass + # if we get here, all presence/absence checks passed self.assertTrue(True) + if __name__ == '__main__': unittest.main() diff --git a/tests/integration/test_dependency_application_id.py b/tests/integration/test_dependency_application_id.py index 666c965a..4fecc7f3 100644 --- a/tests/integration/test_dependency_application_id.py +++ b/tests/integration/test_dependency_application_id.py @@ -20,7 +20,8 @@ class TestDependencyApplicationId(unittest.TestCase): vars_file = os.path.join(role_path, 'vars', 'main.yml') if not os.path.isfile(vars_file): return None - data = yaml.safe_load(open(vars_file, encoding='utf-8')) or {} + with open(vars_file, encoding='utf-8') as f: + data = yaml.safe_load(f) or {} return data.get('application_id') # Iterate all roles @@ -33,7 +34,9 @@ class TestDependencyApplicationId(unittest.TestCase): if not os.path.isfile(meta_file): continue - meta = yaml.safe_load(open(meta_file, encoding='utf-8')) or {} + with open(meta_file, encoding='utf-8') as f: + meta = yaml.safe_load(f) or {} + deps = meta.get('dependencies', []) if not isinstance(deps, list): continue diff --git a/tests/integration/test_get_domain_application_ids.py b/tests/integration/test_get_domain_application_ids.py new file mode 100644 index 00000000..144cc28d --- /dev/null +++ b/tests/integration/test_get_domain_application_ids.py @@ -0,0 +1,46 @@ +import os +import re +import sys +import unittest + +# Ensure filter_plugins is on the path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +sys.path.insert(0, PROJECT_ROOT) + +from filter_plugins.get_all_application_ids import get_all_application_ids + +class TestGetDomainApplicationIds(unittest.TestCase): + """ + Integration test to verify that all string literals passed to get_domain() + correspond to valid application_id values defined in roles/*/vars/main.yml. + """ + + GET_DOMAIN_PATTERN = re.compile(r"get_domain\(\s*['\"]([^'\"]+)['\"]\s*\)") + + def test_get_domain_literals_are_valid_ids(self): + # Collect all application IDs from roles + valid_ids = set(get_all_application_ids()) + + # Walk through project files + invalid_usages = [] + for root, dirs, files in os.walk(PROJECT_ROOT): + # Skip tests directory to avoid matching in test code + if 'tests' in root.split(os.sep): + continue + for fname in files: + if not fname.endswith('.py'): + continue + path = os.path.join(root, fname) + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + for match in self.GET_DOMAIN_PATTERN.finditer(content): + literal = match.group(1) + if literal not in valid_ids: + invalid_usages.append((path, literal)) + + if invalid_usages: + msgs = [f"{path}: '{lit}' is not a valid application_id" for path, lit in invalid_usages] + self.fail("Found invalid get_domain() usages:\n" + "\n".join(msgs)) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/integration/test_roles_folder_names.py b/tests/integration/test_roles_folder_names.py new file mode 100644 index 00000000..28666e6a --- /dev/null +++ b/tests/integration/test_roles_folder_names.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import os +import unittest + +class TestRolesFolderNames(unittest.TestCase): + def test_no_underscore_in_role_folder_names(self): + """ + Integration test that verifies none of the folders under 'roles' contain an underscore in their name, + ignoring the '__pycache__' folder. + """ + # Determine the absolute path to the 'roles' directory + roles_dir = os.path.abspath( + os.path.join( + os.path.dirname(__file__), '..', '..', 'roles' + ) + ) + + # List all entries in the roles directory + try: + entries = os.listdir(roles_dir) + except FileNotFoundError: + self.fail(f"Roles directory not found at expected location: {roles_dir}") + + # Identify any role folders containing underscores, excluding '__pycache__' + invalid = [] + for name in entries: + # Skip the '__pycache__' directory + if name == '__pycache__': + continue + path = os.path.join(roles_dir, name) + if os.path.isdir(path) and '_' in name: + invalid.append(name) + + # Fail the test if any invalid folder names are found + if invalid: + self.fail( + f"Role folder names must not contain underscores: {', '.join(sorted(invalid))}" + ) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/filter_plugins/test_get_all_application_ids.py b/tests/unit/filter_plugins/test_get_all_application_ids.py new file mode 100644 index 00000000..48374bdd --- /dev/null +++ b/tests/unit/filter_plugins/test_get_all_application_ids.py @@ -0,0 +1,55 @@ +import unittest +import tempfile +import os +import yaml + +from filter_plugins.get_all_application_ids import get_all_application_ids + +class TestGetAllApplicationIds(unittest.TestCase): + def setUp(self): + # Create a temporary directory to act as the roles base + self.tmpdir = tempfile.TemporaryDirectory() + self.roles_dir = os.path.join(self.tmpdir.name, 'roles') + os.makedirs(self.roles_dir) + + def tearDown(self): + # Clean up temporary directory + self.tmpdir.cleanup() + + def create_role(self, role_name, data): + # Helper to create roles//vars/main.yml with given dict + path = os.path.join(self.roles_dir, role_name, 'vars') + os.makedirs(path, exist_ok=True) + with open(os.path.join(path, 'main.yml'), 'w', encoding='utf-8') as f: + yaml.safe_dump(data, f) + + def test_single_application_id(self): + self.create_role('role1', {'application_id': 'app1'}) + result = get_all_application_ids(self.roles_dir) + self.assertEqual(result, ['app1']) + + def test_multiple_application_ids(self): + self.create_role('role1', {'application_id': 'app1'}) + self.create_role('role2', {'application_id': 'app2'}) + result = get_all_application_ids(self.roles_dir) + self.assertEqual(sorted(result), ['app1', 'app2']) + + def test_duplicate_application_ids(self): + self.create_role('role1', {'application_id': 'app1'}) + self.create_role('role2', {'application_id': 'app1'}) + result = get_all_application_ids(self.roles_dir) + self.assertEqual(result, ['app1']) + + def test_missing_application_id(self): + self.create_role('role1', {'other_key': 'value'}) + result = get_all_application_ids(self.roles_dir) + self.assertEqual(result, []) + + def test_no_roles_directory(self): + # Point to a non-existent directory + empty_dir = os.path.join(self.tmpdir.name, 'no_roles_here') + result = get_all_application_ids(empty_dir) + self.assertEqual(result, []) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/filter_plugins/test_get_application_id.py b/tests/unit/filter_plugins/test_get_application_id.py new file mode 100644 index 00000000..2891e65a --- /dev/null +++ b/tests/unit/filter_plugins/test_get_application_id.py @@ -0,0 +1,63 @@ +# tests/unit/filter_plugins/test_get_application_id.py +import unittest +import os +import tempfile +import shutil +import yaml +from ansible.errors import AnsibleFilterError +from filter_plugins.get_application_id import get_application_id + +class TestGetApplicationIdFilter(unittest.TestCase): + def setUp(self): + # Create a temporary project directory and switch to it + self.tmpdir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.tmpdir) + + # Create the roles/testrole/vars directory structure + self.role_name = 'testrole' + self.vars_dir = os.path.join('roles', self.role_name, 'vars') + os.makedirs(self.vars_dir) + self.vars_file = os.path.join(self.vars_dir, 'main.yml') + + def tearDown(self): + # Return to original cwd and remove temp directory + os.chdir(self.original_cwd) + shutil.rmtree(self.tmpdir) + + def write_vars_file(self, content): + with open(self.vars_file, 'w') as f: + yaml.dump(content, f) + + def test_returns_application_id(self): + # Given a valid vars file with application_id + expected_id = '12345' + self.write_vars_file({'application_id': expected_id}) + # When + result = get_application_id(self.role_name) + # Then + self.assertEqual(result, expected_id) + + def test_file_not_found_raises_error(self): + # Given no vars file for a nonexistent role + with self.assertRaises(AnsibleFilterError) as cm: + get_application_id('nonexistent_role') + self.assertIn("Vars file not found", str(cm.exception)) + + def test_missing_key_raises_error(self): + # Given a vars file without application_id + self.write_vars_file({'other_key': 'value'}) + with self.assertRaises(AnsibleFilterError) as cm: + get_application_id(self.role_name) + self.assertIn("Key 'application_id' not found", str(cm.exception)) + + def test_invalid_yaml_raises_error(self): + # Write invalid YAML content + with open(self.vars_file, 'w') as f: + f.write(":::not a yaml:::") + with self.assertRaises(AnsibleFilterError) as cm: + get_application_id(self.role_name) + self.assertIn("Error reading YAML", str(cm.exception)) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/filter_plugins/test_get_role_folder.py b/tests/unit/filter_plugins/test_get_role_folder.py index 7530efaf..5af8f120 100644 --- a/tests/unit/filter_plugins/test_get_role_folder.py +++ b/tests/unit/filter_plugins/test_get_role_folder.py @@ -5,7 +5,7 @@ import unittest import yaml from ansible.errors import AnsibleFilterError -from filter_plugins.get_role_folder import get_role_folder +from filter_plugins.get_role import get_role class TestGetRoleFolder(unittest.TestCase): def setUp(self): @@ -35,20 +35,20 @@ class TestGetRoleFolder(unittest.TestCase): def test_find_existing_role(self): # Should find role1 for application_id 'app-123' - result = get_role_folder('app-123', roles_path=self.roles_dir) + result = get_role('app-123', roles_path=self.roles_dir) self.assertEqual(result, 'role1') def test_no_match_raises(self): # No role has application_id 'nonexistent' with self.assertRaises(AnsibleFilterError) as cm: - get_role_folder('nonexistent', roles_path=self.roles_dir) + get_role('nonexistent', roles_path=self.roles_dir) self.assertIn("No role found with application_id 'nonexistent'", str(cm.exception)) def test_missing_roles_path(self): # Path does not exist invalid_path = os.path.join(self.tempdir, 'invalid') with self.assertRaises(AnsibleFilterError) as cm: - get_role_folder('any', roles_path=invalid_path) + get_role('any', roles_path=invalid_path) self.assertIn(f"Roles path not found: {invalid_path}", str(cm.exception)) def test_invalid_yaml_raises(self): @@ -59,7 +59,7 @@ class TestGetRoleFolder(unittest.TestCase): f.write("::: invalid yaml :::") with self.assertRaises(AnsibleFilterError) as cm: - get_role_folder('app-123', roles_path=self.roles_dir) + get_role('app-123', roles_path=self.roles_dir) self.assertIn('Failed to load', str(cm.exception)) if __name__ == '__main__': diff --git a/tests/unit/lookup_plugins/test_docker_cards.py b/tests/unit/lookup_plugins/test_docker_cards.py index 8d1b0a77..cc51c88b 100644 --- a/tests/unit/lookup_plugins/test_docker_cards.py +++ b/tests/unit/lookup_plugins/test_docker_cards.py @@ -1,3 +1,5 @@ +# tests/unit/lookup_plugins/test_docker_cards.py + import os import sys import tempfile @@ -9,14 +11,22 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../roles/web-a from docker_cards import LookupModule + class TestDockerCardsLookup(unittest.TestCase): def setUp(self): # Create a temporary directory to simulate the roles directory. self.test_roles_dir = tempfile.mkdtemp(prefix="test_roles_") - # Create a sample role "web-app-port-ui". + + # Create a sample role "web-app-port-ui" under that directory. self.role_name = "web-app-port-ui" self.role_dir = os.path.join(self.test_roles_dir, self.role_name) os.makedirs(os.path.join(self.role_dir, "meta")) + os.makedirs(os.path.join(self.role_dir, "vars")) + + # Create vars/main.yml so get_application_id() can find the application_id. + vars_main = os.path.join(self.role_dir, "vars", "main.yml") + with open(vars_main, "w", encoding="utf-8") as f: + f.write("application_id: portfolio\n") # Create a sample README.md with a H1 line for the title. readme_path = os.path.join(self.role_dir, "README.md") @@ -54,11 +64,11 @@ galaxy_info: "group_names": ["portfolio"] } result = lookup_module.run([self.test_roles_dir], variables=fake_variables) - + # The result is a list containing one list of card dictionaries. self.assertIsInstance(result, list) self.assertEqual(len(result), 1) - + cards = result[0] self.assertIsInstance(cards, list) # Since "portfolio" is in group_names, one card should be present. @@ -80,21 +90,22 @@ galaxy_info: "applications": { "portfolio": { "features": { - "iframe": True + "portfolio_iframe": True } } }, "group_names": [] # Not including "portfolio" } result = lookup_module.run([self.test_roles_dir], variables=fake_variables) - + # Since the application_id is not in group_names, no card should be added. self.assertIsInstance(result, list) self.assertEqual(len(result), 1) - + cards = result[0] self.assertIsInstance(cards, list) self.assertEqual(len(cards), 0) + if __name__ == "__main__": unittest.main()