mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-04 04:08:15 +00:00 
			
		
		
		
	Implement filter checks: ensure all defined filters are used and remove dead code
Integration tests added/updated:
- tests/integration/test_filters_usage.py: AST-based detection of filter definitions (FilterModule.filters), robust Jinja detection ({{ ... }}, {% ... %}, {% filter ... %}), plus Python call tracking; fails if a filter is used only under tests/.
- tests/integration/test_filters_are_defined.py: inverse check — every filter used in .yml/.yaml/.j2/.jinja2/.tmpl must be defined locally. Scans only inside Jinja blocks and ignores pipes inside strings (e.g., lookup('pipe', "... | grep ... | awk ...")) to avoid false positives like trusted_hosts, woff/woff2, etc.
Bug fixes & robustness:
- Build regexes without %-string formatting to avoid ValueError from literal '%' in Jinja tags.
- Strip quoted strings in usage analysis so sed/grep/awk pipes are not miscounted as filters.
- Prevent self-matches in the defining file.
Cleanup / removal of dead code:
- Removed unused filter plugins and related unit tests:
  * filter_plugins/alias_domains_map.py
  * filter_plugins/get_application_id.py
  * filter_plugins/load_configuration.py
  * filter_plugins/safe.py
  * filter_plugins/safe_join.py
  * roles/svc-db-openldap/filter_plugins/build_ldap_nested_group_entries.py
  * roles/sys-ctl-bkp-docker-2-loc/filter_plugins/dict_to_cli_args.py
  * corresponding tests under tests/unit/*
- roles/svc-db-postgres/filter_plugins/split_postgres_connections.py: dropped no-longer-needed list_postgres_roles API; adjusted tests.
Misc:
- sys-stk-front-proxy/defaults/main.yml: clarified valid vhost_flavour values (comma-separated).
Ref: https://chatgpt.com/share/68b56bac-c4f8-800f-aeef-6708dbb44199
			
			
This commit is contained in:
		@@ -1,86 +0,0 @@
 | 
				
			|||||||
from ansible.errors import AnsibleFilterError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					 | 
				
			||||||
    def filters(self):
 | 
					 | 
				
			||||||
        return {'alias_domains_map': self.alias_domains_map}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def alias_domains_map(self, apps, PRIMARY_DOMAIN):
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Build a map of application IDs to their alias domains.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        - If no `domains` key → []  
 | 
					 | 
				
			||||||
        - If `domains` exists but is an empty dict → return the original cfg  
 | 
					 | 
				
			||||||
        - Explicit `aliases` are used (default appended if missing)  
 | 
					 | 
				
			||||||
        - If only `canonical` defined and it doesn't include default, default is added  
 | 
					 | 
				
			||||||
        - Invalid types raise AnsibleFilterError
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        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
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        def default_domain(app_id, primary):
 | 
					 | 
				
			||||||
            return f"{app_id}.{primary}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 1) Precompute canonical domains per app (fallback to default)
 | 
					 | 
				
			||||||
        canonical_map = {}
 | 
					 | 
				
			||||||
        for app_id, cfg in apps.items():
 | 
					 | 
				
			||||||
            domains_cfg = cfg.get('server',{}).get('domains',{})
 | 
					 | 
				
			||||||
            entry = domains_cfg.get('canonical')
 | 
					 | 
				
			||||||
            if entry is None:
 | 
					 | 
				
			||||||
                canonical_map[app_id] = [default_domain(app_id, PRIMARY_DOMAIN)]
 | 
					 | 
				
			||||||
            elif isinstance(entry, dict):
 | 
					 | 
				
			||||||
                canonical_map[app_id] = list(entry.values())
 | 
					 | 
				
			||||||
            elif isinstance(entry, list):
 | 
					 | 
				
			||||||
                canonical_map[app_id] = list(entry)
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                raise AnsibleFilterError(
 | 
					 | 
				
			||||||
                    f"Unexpected type for 'server.domains.canonical' in application '{app_id}': {type(entry).__name__}"
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 2) Build alias list per app
 | 
					 | 
				
			||||||
        result = {}
 | 
					 | 
				
			||||||
        for app_id, cfg in apps.items():
 | 
					 | 
				
			||||||
            domains_cfg = cfg.get('server',{}).get('domains')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            # no domains key → no aliases
 | 
					 | 
				
			||||||
            if domains_cfg is None:
 | 
					 | 
				
			||||||
                result[app_id] = []
 | 
					 | 
				
			||||||
                continue
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            # empty domains dict → return the original cfg
 | 
					 | 
				
			||||||
            if isinstance(domains_cfg, dict) and not domains_cfg:
 | 
					 | 
				
			||||||
                result[app_id] = cfg
 | 
					 | 
				
			||||||
                continue
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            # otherwise, compute aliases
 | 
					 | 
				
			||||||
            aliases = parse_entry(domains_cfg, 'aliases', app_id) or []
 | 
					 | 
				
			||||||
            default = default_domain(app_id, PRIMARY_DOMAIN)
 | 
					 | 
				
			||||||
            has_aliases = 'aliases' in domains_cfg
 | 
					 | 
				
			||||||
            has_canon   = 'canonical' in domains_cfg
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if has_aliases:
 | 
					 | 
				
			||||||
                if default not in aliases:
 | 
					 | 
				
			||||||
                    aliases.append(default)
 | 
					 | 
				
			||||||
            elif has_canon:
 | 
					 | 
				
			||||||
                canon = canonical_map.get(app_id, [])
 | 
					 | 
				
			||||||
                if default not in canon and default not in aliases:
 | 
					 | 
				
			||||||
                    aliases.append(default)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            result[app_id] = aliases
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return result
 | 
					 | 
				
			||||||
@@ -1,49 +0,0 @@
 | 
				
			|||||||
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,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
@@ -1,122 +0,0 @@
 | 
				
			|||||||
import os
 | 
					 | 
				
			||||||
import yaml
 | 
					 | 
				
			||||||
import re
 | 
					 | 
				
			||||||
from ansible.errors import AnsibleFilterError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# in-memory cache: application_id → (parsed_yaml, is_nested)
 | 
					 | 
				
			||||||
_cfg_cache = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def load_configuration(application_id, key):
 | 
					 | 
				
			||||||
    if not isinstance(key, str):
 | 
					 | 
				
			||||||
        raise AnsibleFilterError("Key must be a dotted-string, e.g. 'features.matomo'")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # locate roles/
 | 
					 | 
				
			||||||
    here = os.path.dirname(__file__)
 | 
					 | 
				
			||||||
    root = os.path.abspath(os.path.join(here, '..'))
 | 
					 | 
				
			||||||
    roles_dir = os.path.join(root, 'roles')
 | 
					 | 
				
			||||||
    if not os.path.isdir(roles_dir):
 | 
					 | 
				
			||||||
        raise AnsibleFilterError(f"Roles directory not found at {roles_dir}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # first time? load & cache
 | 
					 | 
				
			||||||
    if application_id not in _cfg_cache:
 | 
					 | 
				
			||||||
        config_path = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 1) primary: vars/main.yml declares it
 | 
					 | 
				
			||||||
        for role in os.listdir(roles_dir):
 | 
					 | 
				
			||||||
            mv = os.path.join(roles_dir, role, 'vars', 'main.yml')
 | 
					 | 
				
			||||||
            if os.path.exists(mv):
 | 
					 | 
				
			||||||
                try:
 | 
					 | 
				
			||||||
                    md = yaml.safe_load(open(mv)) or {}
 | 
					 | 
				
			||||||
                except Exception:
 | 
					 | 
				
			||||||
                    md = {}
 | 
					 | 
				
			||||||
                if md.get('application_id') == application_id:
 | 
					 | 
				
			||||||
                    cf = os.path.join(roles_dir, role, "config" , "main.yml")
 | 
					 | 
				
			||||||
                    if not os.path.exists(cf):
 | 
					 | 
				
			||||||
                        raise AnsibleFilterError(
 | 
					 | 
				
			||||||
                            f"Role '{role}' declares '{application_id}' but missing config/main.yml"
 | 
					 | 
				
			||||||
                        )
 | 
					 | 
				
			||||||
                    config_path = cf
 | 
					 | 
				
			||||||
                    break
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 2) fallback nested
 | 
					 | 
				
			||||||
        if config_path is None:
 | 
					 | 
				
			||||||
            for role in os.listdir(roles_dir):
 | 
					 | 
				
			||||||
                cf = os.path.join(roles_dir, role, "config" , "main.yml")
 | 
					 | 
				
			||||||
                if not os.path.exists(cf):
 | 
					 | 
				
			||||||
                    continue
 | 
					 | 
				
			||||||
                try:
 | 
					 | 
				
			||||||
                    dd = yaml.safe_load(open(cf)) or {}
 | 
					 | 
				
			||||||
                except Exception:
 | 
					 | 
				
			||||||
                    dd = {}
 | 
					 | 
				
			||||||
                if isinstance(dd, dict) and application_id in dd:
 | 
					 | 
				
			||||||
                    config_path = cf
 | 
					 | 
				
			||||||
                    break
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # 3) fallback flat
 | 
					 | 
				
			||||||
        if config_path is None:
 | 
					 | 
				
			||||||
            for role in os.listdir(roles_dir):
 | 
					 | 
				
			||||||
                cf = os.path.join(roles_dir, role, "config" , "main.yml")
 | 
					 | 
				
			||||||
                if not os.path.exists(cf):
 | 
					 | 
				
			||||||
                    continue
 | 
					 | 
				
			||||||
                try:
 | 
					 | 
				
			||||||
                    dd = yaml.safe_load(open(cf)) or {}
 | 
					 | 
				
			||||||
                except Exception:
 | 
					 | 
				
			||||||
                    dd = {}
 | 
					 | 
				
			||||||
                # flat style: dict with all non-dict values
 | 
					 | 
				
			||||||
                if isinstance(dd, dict) and not any(isinstance(v, dict) for v in dd.values()):
 | 
					 | 
				
			||||||
                    config_path = cf
 | 
					 | 
				
			||||||
                    break
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if config_path is None:
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # parse once
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            parsed = yaml.safe_load(open(config_path)) or {}
 | 
					 | 
				
			||||||
        except Exception as e:
 | 
					 | 
				
			||||||
            raise AnsibleFilterError(f"Error loading config/main.yml at {config_path}: {e}")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # detect nested vs flat
 | 
					 | 
				
			||||||
        is_nested = isinstance(parsed, dict) and (application_id in parsed)
 | 
					 | 
				
			||||||
        _cfg_cache[application_id] = (parsed, is_nested)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    parsed, is_nested = _cfg_cache[application_id]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # pick base entry
 | 
					 | 
				
			||||||
    entry = parsed[application_id] if is_nested else parsed
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # resolve dotted key
 | 
					 | 
				
			||||||
    key_parts = key.split('.')
 | 
					 | 
				
			||||||
    for part in key_parts:
 | 
					 | 
				
			||||||
        # Check if part has an index (e.g., domains.canonical[0])
 | 
					 | 
				
			||||||
        match = re.match(r'([^\[]+)\[([0-9]+)\]', part)
 | 
					 | 
				
			||||||
        if match:
 | 
					 | 
				
			||||||
            part, index = match.groups()
 | 
					 | 
				
			||||||
            index = int(index)
 | 
					 | 
				
			||||||
            if isinstance(entry, dict) and part in entry:
 | 
					 | 
				
			||||||
                entry = entry[part]
 | 
					 | 
				
			||||||
                # Check if entry is a list and access the index
 | 
					 | 
				
			||||||
                if isinstance(entry, list) and 0 <= index < len(entry):
 | 
					 | 
				
			||||||
                    entry = entry[index]
 | 
					 | 
				
			||||||
                else:
 | 
					 | 
				
			||||||
                    raise AnsibleFilterError(
 | 
					 | 
				
			||||||
                        f"Index '{index}' out of range for key '{part}' in application '{application_id}'"
 | 
					 | 
				
			||||||
                    )
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                raise AnsibleFilterError(
 | 
					 | 
				
			||||||
                    f"Key '{part}' not found under application '{application_id}'"
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            if isinstance(entry, dict) and part in entry:
 | 
					 | 
				
			||||||
                entry = entry[part]
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                raise AnsibleFilterError(
 | 
					 | 
				
			||||||
                    f"Key '{part}' not found under application '{application_id}'"
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return entry
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					 | 
				
			||||||
    def filters(self):
 | 
					 | 
				
			||||||
        return {'load_configuration': load_configuration}
 | 
					 | 
				
			||||||
@@ -1,55 +0,0 @@
 | 
				
			|||||||
from jinja2 import Undefined
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def safe_placeholders(template: str, mapping: dict = None) -> str:
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Format a template like "{url}/logo.png".
 | 
					 | 
				
			||||||
    If mapping is provided (not None) and ANY placeholder is missing or maps to None/empty string, the function will raise KeyError.
 | 
					 | 
				
			||||||
    If mapping is None, missing placeholders or invalid templates return empty string.
 | 
					 | 
				
			||||||
    Numerical zero or False are considered valid values.
 | 
					 | 
				
			||||||
    Any other formatting errors return an empty string.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    # Non-string templates yield empty
 | 
					 | 
				
			||||||
    if not isinstance(template, str):
 | 
					 | 
				
			||||||
        return ''
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    class SafeDict(dict):
 | 
					 | 
				
			||||||
        def __getitem__(self, key):
 | 
					 | 
				
			||||||
            val = super().get(key, None)
 | 
					 | 
				
			||||||
            # Treat None or empty string as missing
 | 
					 | 
				
			||||||
            if val is None or (isinstance(val, str) and val == ''):
 | 
					 | 
				
			||||||
                raise KeyError(key)
 | 
					 | 
				
			||||||
            return val
 | 
					 | 
				
			||||||
        def __missing__(self, key):
 | 
					 | 
				
			||||||
            raise KeyError(key)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    silent = mapping is None
 | 
					 | 
				
			||||||
    data = mapping or {}
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        return template.format_map(SafeDict(data))
 | 
					 | 
				
			||||||
    except KeyError:
 | 
					 | 
				
			||||||
        if silent:
 | 
					 | 
				
			||||||
            return ''
 | 
					 | 
				
			||||||
        raise
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        return ''
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def safe_var(value):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Ansible filter: returns the value unchanged unless it's Undefined or None,
 | 
					 | 
				
			||||||
    in which case returns an empty string.
 | 
					 | 
				
			||||||
    Catches all exceptions and yields ''.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        if isinstance(value, Undefined) or value is None:
 | 
					 | 
				
			||||||
            return ''
 | 
					 | 
				
			||||||
        return value
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        return ''
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					 | 
				
			||||||
    def filters(self):
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            'safe_var': safe_var,
 | 
					 | 
				
			||||||
            'safe_placeholders': safe_placeholders,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
@@ -1,29 +0,0 @@
 | 
				
			|||||||
"""
 | 
					 | 
				
			||||||
Ansible filter plugin that joins a base string and a tail path safely.
 | 
					 | 
				
			||||||
Raises ValueError if base or tail is None.
 | 
					 | 
				
			||||||
"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def safe_join(base, tail):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Safely join base and tail into a path or URL.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    - base: the base string. Must not be None.
 | 
					 | 
				
			||||||
    - tail: the string to append. Must not be None.
 | 
					 | 
				
			||||||
    - On ValueError, caller should handle it.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    if base is None or tail is None:
 | 
					 | 
				
			||||||
        raise ValueError("safe_join: base and tail must not be None")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        base_str = str(base).rstrip('/')
 | 
					 | 
				
			||||||
        tail_str = str(tail).lstrip('/')
 | 
					 | 
				
			||||||
        return f"{base_str}/{tail_str}"
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        return ''
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					 | 
				
			||||||
    def filters(self):
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            'safe_join': safe_join,
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
@@ -1,77 +0,0 @@
 | 
				
			|||||||
def build_ldap_nested_group_entries(applications, users, ldap):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Builds structured LDAP role entries using the global `ldap` configuration.
 | 
					 | 
				
			||||||
    Supports objectClasses: posixGroup (adds gidNumber, memberUid), groupOfNames (adds member).
 | 
					 | 
				
			||||||
    Now nests roles under an application-level OU: application-id/role.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    result = {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Base DN components
 | 
					 | 
				
			||||||
    role_dn_base = ldap["DN"]["OU"]["ROLES"]
 | 
					 | 
				
			||||||
    user_dn_base = ldap["DN"]["OU"]["USERS"]
 | 
					 | 
				
			||||||
    ldap_user_attr = ldap["USER"]["ATTRIBUTES"]["ID"]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Supported objectClass flavors
 | 
					 | 
				
			||||||
    flavors = ldap.get("RBAC").get("FLAVORS")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for application_id, app_config in applications.items():
 | 
					 | 
				
			||||||
        # Compute the DN for the application-level OU
 | 
					 | 
				
			||||||
        app_ou_dn = f"ou={application_id},{role_dn_base}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        ou_entry = {
 | 
					 | 
				
			||||||
            "dn": app_ou_dn,
 | 
					 | 
				
			||||||
            "objectClass": ["top", "organizationalUnit"],
 | 
					 | 
				
			||||||
            "ou": application_id,
 | 
					 | 
				
			||||||
            "description": f"Roles for application {application_id}" 
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        result[app_ou_dn] = ou_entry
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Standard roles with an extra 'administrator'
 | 
					 | 
				
			||||||
        base_roles = app_config.get("rbac", {}).get("roles", {})
 | 
					 | 
				
			||||||
        roles = {
 | 
					 | 
				
			||||||
            **base_roles,
 | 
					 | 
				
			||||||
            "administrator": {
 | 
					 | 
				
			||||||
                "description": "Has full administrative access: manage themes, plugins, settings, and users"
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        group_id = app_config.get("group_id")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        for role_name, role_conf in roles.items():
 | 
					 | 
				
			||||||
            # Build CN under the application OU
 | 
					 | 
				
			||||||
            cn = role_name
 | 
					 | 
				
			||||||
            dn = f"cn={cn},{app_ou_dn}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            entry = {
 | 
					 | 
				
			||||||
                "dn": dn,
 | 
					 | 
				
			||||||
                "cn": cn,
 | 
					 | 
				
			||||||
                "description": role_conf.get("description", ""),
 | 
					 | 
				
			||||||
                "objectClass": ["top"] + flavors,
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            member_dns = []
 | 
					 | 
				
			||||||
            member_uids = []
 | 
					 | 
				
			||||||
            for username, user_conf in users.items():
 | 
					 | 
				
			||||||
                if role_name in user_conf.get("roles", []):
 | 
					 | 
				
			||||||
                    member_dns.append(f"{ldap_user_attr}={username},{user_dn_base}")
 | 
					 | 
				
			||||||
                    member_uids.append(username)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if "posixGroup" in flavors:
 | 
					 | 
				
			||||||
                entry["gidNumber"] = group_id
 | 
					 | 
				
			||||||
                if member_uids:
 | 
					 | 
				
			||||||
                    entry["memberUid"] = member_uids
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            if "groupOfNames" in flavors and member_dns:
 | 
					 | 
				
			||||||
                entry["member"] = member_dns
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            result[dn] = entry
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return result
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					 | 
				
			||||||
    def filters(self):
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            "build_ldap_nested_group_entries": build_ldap_nested_group_entries
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
@@ -37,22 +37,8 @@ def split_postgres_connections(total_connections, roles_dir="roles"):
 | 
				
			|||||||
    denom = max(count, 1)
 | 
					    denom = max(count, 1)
 | 
				
			||||||
    return max(1, total // denom)
 | 
					    return max(1, total // denom)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def list_postgres_roles(roles_dir="roles"):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Helper: return a list of role names that declare database_type: postgres in vars/main.yml.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    names = []
 | 
					 | 
				
			||||||
    if not os.path.isdir(roles_dir):
 | 
					 | 
				
			||||||
        return names
 | 
					 | 
				
			||||||
    for name in os.listdir(roles_dir):
 | 
					 | 
				
			||||||
        vars_main = os.path.join(roles_dir, name, "vars", "main.yml")
 | 
					 | 
				
			||||||
        if os.path.isfile(vars_main) and _is_postgres_role(vars_main):
 | 
					 | 
				
			||||||
            names.append(name)
 | 
					 | 
				
			||||||
    return names
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					class FilterModule(object):
 | 
				
			||||||
    def filters(self):
 | 
					    def filters(self):
 | 
				
			||||||
        return {
 | 
					        return {
 | 
				
			||||||
            "split_postgres_connections": split_postgres_connections,
 | 
					            "split_postgres_connections": split_postgres_connections
 | 
				
			||||||
            "list_postgres_roles": list_postgres_roles,
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,36 +0,0 @@
 | 
				
			|||||||
def dict_to_cli_args(data):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Convert a dictionary into CLI argument string.
 | 
					 | 
				
			||||||
    Example:
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        "backup-dir": "/mnt/backups",
 | 
					 | 
				
			||||||
        "shutdown": True,
 | 
					 | 
				
			||||||
        "ignore-volumes": ["redis", "memcached"]
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    becomes:
 | 
					 | 
				
			||||||
    --backup-dir=/mnt/backups --shutdown --ignore-volumes="redis memcached"
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    if not isinstance(data, dict):
 | 
					 | 
				
			||||||
        raise TypeError("Expected a dictionary for CLI argument conversion")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    args = []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for key, value in data.items():
 | 
					 | 
				
			||||||
        cli_key = f"--{key}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if isinstance(value, bool):
 | 
					 | 
				
			||||||
            if value:
 | 
					 | 
				
			||||||
                args.append(cli_key)
 | 
					 | 
				
			||||||
        elif isinstance(value, list):
 | 
					 | 
				
			||||||
            items = " ".join(map(str, value))
 | 
					 | 
				
			||||||
            args.append(f'{cli_key}="{items}"')
 | 
					 | 
				
			||||||
        elif value is not None:
 | 
					 | 
				
			||||||
            args.append(f'{cli_key}={value}')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return " ".join(args)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class FilterModule(object):
 | 
					 | 
				
			||||||
    def filters(self):
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
            'dict_to_cli_args': dict_to_cli_args
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
# default vhost flavour
 | 
					# default vhost flavour
 | 
				
			||||||
vhost_flavour:        "basic"               # valid: basic | ws_generic
 | 
					vhost_flavour:        "basic"               # valid: basic, ws_generic
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# build the full template path from the flavour
 | 
					# build the full template path from the flavour
 | 
				
			||||||
vhost_template_src:   "roles/srv-proxy-core/templates/vhost/{{ vhost_flavour }}.conf.j2"
 | 
					vhost_template_src:   "roles/srv-proxy-core/templates/vhost/{{ vhost_flavour }}.conf.j2"
 | 
				
			||||||
							
								
								
									
										252
									
								
								tests/integration/test_filters_are_defined.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								tests/integration/test_filters_are_defined.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,252 @@
 | 
				
			|||||||
 | 
					# tests/integration/test_filters_are_defined.py
 | 
				
			||||||
 | 
					import ast
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					from typing import Dict, List, Set, Tuple
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Where filter definitions may exist
 | 
				
			||||||
 | 
					FILTER_PLUGIN_BASES = [
 | 
				
			||||||
 | 
					    os.path.join(PROJECT_ROOT, "filter_plugins"),
 | 
				
			||||||
 | 
					    os.path.join(PROJECT_ROOT, "roles"),  # includes roles/*/filter_plugins
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Where to search for usages (EXCLUDES tests/ by default)
 | 
				
			||||||
 | 
					SEARCH_BASES = [PROJECT_ROOT]
 | 
				
			||||||
 | 
					EXCLUDE_TESTS = True  # keep True to require real usage sites
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# File extensions to scan for template usage
 | 
				
			||||||
 | 
					USAGE_EXTS = (".yml", ".yaml", ".j2", ".jinja2", ".tmpl")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Built-in / common filters that shouldn't require local definitions
 | 
				
			||||||
 | 
					BUILTIN_FILTERS: Set[str] = {
 | 
				
			||||||
 | 
					    # Jinja2 core/common
 | 
				
			||||||
 | 
					    "abs", "attr", "batch", "capitalize", "center", "default", "d", "dictsort", "escape",
 | 
				
			||||||
 | 
					    "e", "filesizeformat", "first", "float", "forceescape", "format", "groupby", "indent",
 | 
				
			||||||
 | 
					    "int", "join", "last", "length", "list", "lower", "map", "min", "max", "random",
 | 
				
			||||||
 | 
					    "reject", "rejectattr", "replace", "reverse", "round", "safe", "select",
 | 
				
			||||||
 | 
					    "selectattr", "slice", "sort", "string", "striptags", "sum", "title", "trim",
 | 
				
			||||||
 | 
					    "truncate", "unique", "upper", "urlencode", "urlize", "wordcount", "xmlattr",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Common Ansible filters (subset, extend as needed)
 | 
				
			||||||
 | 
					    "b64decode", "b64encode", "basename", "dirname", "from_json", "to_json",
 | 
				
			||||||
 | 
					    "from_yaml", "to_yaml", "combine", "difference", "intersect",
 | 
				
			||||||
 | 
					    "flatten", "zip", "regex_search", "regex_replace", "bool",
 | 
				
			||||||
 | 
					    "type_debug", "json_query", "mandatory", "hash", "checksum",
 | 
				
			||||||
 | 
					    "lower", "upper", "capitalize", "unique", "dict2items", "items2dict", "password_hash", "path_join", "product", "quote", "split", "ternary", "to_nice_yaml", "tojson",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Date/time-ish
 | 
				
			||||||
 | 
					    "strftime",
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _iter_files(base: str, *, exts: Tuple[str, ...]):
 | 
				
			||||||
 | 
					    for root, _, files in os.walk(base):
 | 
				
			||||||
 | 
					        if EXCLUDE_TESTS and (os.sep + "tests" + os.sep) in (root + os.sep):
 | 
				
			||||||
 | 
					            continue
 | 
				
			||||||
 | 
					        for fn in files:
 | 
				
			||||||
 | 
					            if fn.endswith(exts):
 | 
				
			||||||
 | 
					                yield os.path.join(root, fn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _is_filter_plugins_dir(path: str) -> bool:
 | 
				
			||||||
 | 
					    return "filter_plugins" in os.path.normpath(path).split(os.sep)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _read(path: str) -> str:
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        with open(path, "r", encoding="utf-8", errors="ignore") as f:
 | 
				
			||||||
 | 
					            return f.read()
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					# Collect defined filters (AST)
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _FiltersCollector(ast.NodeVisitor):
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        self.defs: List[Tuple[str, str]] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def visit_Return(self, node: ast.Return):
 | 
				
			||||||
 | 
					        self.defs.extend(self._extract_mapping(node.value))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _extract_mapping(self, node) -> List[Tuple[str, str]]:
 | 
				
			||||||
 | 
					        pairs: List[Tuple[str, str]] = []
 | 
				
			||||||
 | 
					        if isinstance(node, ast.Dict):
 | 
				
			||||||
 | 
					            for k, v in zip(node.keys, node.values):
 | 
				
			||||||
 | 
					                key = k.value if isinstance(k, ast.Constant) and isinstance(k.value, str) else None
 | 
				
			||||||
 | 
					                val = self._name_of(v)
 | 
				
			||||||
 | 
					                if key:
 | 
				
			||||||
 | 
					                    pairs.append((key, val))
 | 
				
			||||||
 | 
					            return pairs
 | 
				
			||||||
 | 
					        if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "dict":
 | 
				
			||||||
 | 
					            for kw in node.keywords or []:
 | 
				
			||||||
 | 
					                if kw.arg:
 | 
				
			||||||
 | 
					                    pairs.append((kw.arg, self._name_of(kw.value)))
 | 
				
			||||||
 | 
					            return pairs
 | 
				
			||||||
 | 
					        if isinstance(node, ast.Name):
 | 
				
			||||||
 | 
					            return []
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def _name_of(v) -> str:
 | 
				
			||||||
 | 
					        if isinstance(v, ast.Name):
 | 
				
			||||||
 | 
					            return v.id
 | 
				
			||||||
 | 
					        if isinstance(v, ast.Attribute):
 | 
				
			||||||
 | 
					            return v.attr
 | 
				
			||||||
 | 
					        return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _collect_filters_from_filters_method(func: ast.FunctionDef) -> List[Tuple[str, str]]:
 | 
				
			||||||
 | 
					    c = _FiltersCollector()
 | 
				
			||||||
 | 
					    c.visit(func)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    name_dicts: Dict[str, List[Tuple[str, str]]] = {}
 | 
				
			||||||
 | 
					    returned_names: List[str] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for n in ast.walk(func):
 | 
				
			||||||
 | 
					        if isinstance(n, ast.Assign):
 | 
				
			||||||
 | 
					            if len(n.targets) == 1 and isinstance(n.targets[0], ast.Name):
 | 
				
			||||||
 | 
					                tgt = n.targets[0].id
 | 
				
			||||||
 | 
					                pairs = _FiltersCollector()._extract_mapping(n.value)
 | 
				
			||||||
 | 
					                if pairs:
 | 
				
			||||||
 | 
					                    name_dicts.setdefault(tgt, []).extend(pairs)
 | 
				
			||||||
 | 
					        elif isinstance(n, ast.Call):
 | 
				
			||||||
 | 
					            if isinstance(n.func, ast.Attribute) and n.func.attr == "update":
 | 
				
			||||||
 | 
					                obj = n.func.value
 | 
				
			||||||
 | 
					                if isinstance(obj, ast.Name) and n.args:
 | 
				
			||||||
 | 
					                    add_pairs = _FiltersCollector()._extract_mapping(n.args[0])
 | 
				
			||||||
 | 
					                    if add_pairs:
 | 
				
			||||||
 | 
					                        name_dicts.setdefault(obj.id, []).extend(add_pairs)
 | 
				
			||||||
 | 
					        elif isinstance(n, ast.Return) and isinstance(n.value, ast.Name):
 | 
				
			||||||
 | 
					            returned_names.append(n.value.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for nm in returned_names:
 | 
				
			||||||
 | 
					        for p in name_dicts.get(nm, []):
 | 
				
			||||||
 | 
					            c.defs.append(p)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # dedupe
 | 
				
			||||||
 | 
					    seen = set()
 | 
				
			||||||
 | 
					    out: List[Tuple[str, str]] = []
 | 
				
			||||||
 | 
					    for k, v in c.defs:
 | 
				
			||||||
 | 
					        if (k, v) not in seen:
 | 
				
			||||||
 | 
					            seen.add((k, v))
 | 
				
			||||||
 | 
					            out.append((k, v))
 | 
				
			||||||
 | 
					    return out
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def collect_defined_filters() -> Set[str]:
 | 
				
			||||||
 | 
					    defined: Set[str] = set()
 | 
				
			||||||
 | 
					    for base in FILTER_PLUGIN_BASES:
 | 
				
			||||||
 | 
					        for path in _iter_files(base, exts=(".py",)):
 | 
				
			||||||
 | 
					            if not _is_filter_plugins_dir(path):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            code = _read(path)
 | 
				
			||||||
 | 
					            if not code:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                tree = ast.parse(code, filename=path)
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            for node in tree.body:
 | 
				
			||||||
 | 
					                if isinstance(node, ast.ClassDef) and node.name == "FilterModule":
 | 
				
			||||||
 | 
					                    for item in node.body:
 | 
				
			||||||
 | 
					                        if isinstance(item, ast.FunctionDef) and item.name == "filters":
 | 
				
			||||||
 | 
					                            for fname, _call in _collect_filters_from_filters_method(item):
 | 
				
			||||||
 | 
					                                defined.add(fname)
 | 
				
			||||||
 | 
					    return defined
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					# Collect used filters (Jinja-only scanning with string stripping)
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Capture inner bodies of Jinja blocks
 | 
				
			||||||
 | 
					RE_JINJA_MUSTACHE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
 | 
				
			||||||
 | 
					RE_JINJA_TAG      = re.compile(r"\{%(.*?)%\}", re.DOTALL)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Within a Jinja body, capture "| filter_name" (with args or not)
 | 
				
			||||||
 | 
					RE_PIPE_IN_BODY   = re.compile(r"\|\s*([A-Za-z_]\w*)\b")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Matches "{% filter filter_name %}"
 | 
				
			||||||
 | 
					RE_BLOCK_FILTER   = re.compile(r"\{%\s*filter\s+([A-Za-z_]\w*)\b", re.DOTALL)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _strip_quoted(text: str) -> str:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Remove content inside single/double quotes to avoid false positives for pipes in strings,
 | 
				
			||||||
 | 
					    e.g. lookup('pipe', "pacman ... | grep ... | awk ...") -> pipes are ignored.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    out = []
 | 
				
			||||||
 | 
					    i = 0
 | 
				
			||||||
 | 
					    n = len(text)
 | 
				
			||||||
 | 
					    quote = None
 | 
				
			||||||
 | 
					    while i < n:
 | 
				
			||||||
 | 
					        ch = text[i]
 | 
				
			||||||
 | 
					        if quote is None:
 | 
				
			||||||
 | 
					            if ch in ("'", '"'):
 | 
				
			||||||
 | 
					                quote = ch
 | 
				
			||||||
 | 
					                i += 1
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            out.append(ch)
 | 
				
			||||||
 | 
					            i += 1
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # inside quotes; handle simple escapes \" and \'
 | 
				
			||||||
 | 
					            if ch == "\\" and i + 1 < n:
 | 
				
			||||||
 | 
					                i += 2
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            if ch == quote:
 | 
				
			||||||
 | 
					                quote = None
 | 
				
			||||||
 | 
					            i += 1
 | 
				
			||||||
 | 
					    return "".join(out)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _extract_filters_from_jinja_body(body: str) -> Set[str]:
 | 
				
			||||||
 | 
					    # Strip quoted strings first so pipes inside strings are ignored
 | 
				
			||||||
 | 
					    body_no_str = _strip_quoted(body)
 | 
				
			||||||
 | 
					    return {m.group(1) for m in RE_PIPE_IN_BODY.finditer(body_no_str)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def collect_used_filters() -> Set[str]:
 | 
				
			||||||
 | 
					    used: Set[str] = set()
 | 
				
			||||||
 | 
					    for base in SEARCH_BASES:
 | 
				
			||||||
 | 
					        for path in _iter_files(base, exts=USAGE_EXTS):
 | 
				
			||||||
 | 
					            text = _read(path)
 | 
				
			||||||
 | 
					            if not text:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # 1) Filters used in {{ ... }} blocks
 | 
				
			||||||
 | 
					            for m in RE_JINJA_MUSTACHE.finditer(text):
 | 
				
			||||||
 | 
					                used |= _extract_filters_from_jinja_body(m.group(1))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # 2) Filters used in {% ... %} blocks (e.g., set, if, for)
 | 
				
			||||||
 | 
					            for m in RE_JINJA_TAG.finditer(text):
 | 
				
			||||||
 | 
					                used |= _extract_filters_from_jinja_body(m.group(1))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # 3) Block filter form: {% filter name %} ... {% endfilter %}
 | 
				
			||||||
 | 
					            for m in RE_BLOCK_FILTER.finditer(text):
 | 
				
			||||||
 | 
					                used.add(m.group(1))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return used
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					# Test
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestAllUsedFiltersAreDefined(unittest.TestCase):
 | 
				
			||||||
 | 
					    def test_all_used_filters_have_definitions(self):
 | 
				
			||||||
 | 
					        defined = collect_defined_filters()
 | 
				
			||||||
 | 
					        used = collect_used_filters()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Remove built-ins and known-safe filters
 | 
				
			||||||
 | 
					        candidates = sorted(used - BUILTIN_FILTERS)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Unknown filters are those not defined locally
 | 
				
			||||||
 | 
					        unknown = [f for f in candidates if f not in defined]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if unknown:
 | 
				
			||||||
 | 
					            lines = [
 | 
				
			||||||
 | 
					                "These filters are used in templates/YAML but have no local definition "
 | 
				
			||||||
 | 
					                "(and are not in BUILTIN_FILTERS):"
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					            for f in unknown:
 | 
				
			||||||
 | 
					                lines.append(f"- " + f)
 | 
				
			||||||
 | 
					            self.fail("\n".join(lines))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    unittest.main()
 | 
				
			||||||
							
								
								
									
										260
									
								
								tests/integration/test_filters_usage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								tests/integration/test_filters_usage.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,260 @@
 | 
				
			|||||||
 | 
					import ast
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					import unittest
 | 
				
			||||||
 | 
					from typing import Dict, List, Tuple, Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					FILTER_PLUGIN_BASES = [
 | 
				
			||||||
 | 
					    os.path.join(PROJECT_ROOT, "filter_plugins"),
 | 
				
			||||||
 | 
					    os.path.join(PROJECT_ROOT, "roles"),
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SEARCH_BASES = [PROJECT_ROOT]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					SEARCH_EXTS = (".yml", ".yaml", ".j2", ".jinja2", ".tmpl", ".py")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _iter_files(base: str, *, py_only: bool = False):
 | 
				
			||||||
 | 
					    for root, _, files in os.walk(base):
 | 
				
			||||||
 | 
					        for fn in files:
 | 
				
			||||||
 | 
					            if py_only and not fn.endswith(".py"):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            if not py_only and not fn.endswith(SEARCH_EXTS):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            yield os.path.join(root, fn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _is_filter_plugins_dir(path: str) -> bool:
 | 
				
			||||||
 | 
					    return "filter_plugins" in os.path.normpath(path).split(os.sep)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _read(path: str) -> str:
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        with open(path, "r", encoding="utf-8", errors="ignore") as f:
 | 
				
			||||||
 | 
					            return f.read()
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					# Filter definition extraction
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _FiltersCollector(ast.NodeVisitor):
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Extract mappings returned by FilterModule.filters().
 | 
				
			||||||
 | 
					    Handles:
 | 
				
			||||||
 | 
					      return {'name': fn, "x": y}
 | 
				
			||||||
 | 
					      d = {'name': fn}; d.update({...}); return d
 | 
				
			||||||
 | 
					      return dict(name=fn, x=y)
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    def __init__(self):
 | 
				
			||||||
 | 
					        self.defs: List[Tuple[str, str]] = []  # (filter_name, callable_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def visit_Return(self, node: ast.Return):
 | 
				
			||||||
 | 
					        mapping = self._extract_mapping(node.value)
 | 
				
			||||||
 | 
					        for k, v in mapping:
 | 
				
			||||||
 | 
					            self.defs.append((k, v))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _extract_mapping(self, node) -> List[Tuple[str, str]]:
 | 
				
			||||||
 | 
					        pairs: List[Tuple[str, str]] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # dict literal
 | 
				
			||||||
 | 
					        if isinstance(node, ast.Dict):
 | 
				
			||||||
 | 
					            for k, v in zip(node.keys, node.values):
 | 
				
			||||||
 | 
					                key = k.value if isinstance(k, ast.Constant) and isinstance(k.value, str) else None
 | 
				
			||||||
 | 
					                val = self._name_of(v)
 | 
				
			||||||
 | 
					                if key:
 | 
				
			||||||
 | 
					                    pairs.append((key, val))
 | 
				
			||||||
 | 
					            return pairs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # dict(...) call
 | 
				
			||||||
 | 
					        if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "dict":
 | 
				
			||||||
 | 
					            # keywords: dict(name=fn)
 | 
				
			||||||
 | 
					            for kw in node.keywords or []:
 | 
				
			||||||
 | 
					                if kw.arg:
 | 
				
			||||||
 | 
					                    pairs.append((kw.arg, self._name_of(kw.value)))
 | 
				
			||||||
 | 
					            return pairs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Name (variable) that might be a dict assembled earlier in the function
 | 
				
			||||||
 | 
					        if isinstance(node, ast.Name):
 | 
				
			||||||
 | 
					            # Fallback: we can't easily dataflow-resolve here; handled elsewhere by walking Assign/Call
 | 
				
			||||||
 | 
					            return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    def _name_of(v) -> str:
 | 
				
			||||||
 | 
					        if isinstance(v, ast.Name):
 | 
				
			||||||
 | 
					            return v.id
 | 
				
			||||||
 | 
					        if isinstance(v, ast.Attribute):
 | 
				
			||||||
 | 
					            return v.attr  # take right-most name
 | 
				
			||||||
 | 
					        return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _collect_filters_from_filters_method(func: ast.FunctionDef) -> List[Tuple[str, str]]:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Walks the function to assemble any mapping that flows into the return.
 | 
				
			||||||
 | 
					    We capture direct return dicts and also a common pattern:
 | 
				
			||||||
 | 
					        d = {...}
 | 
				
			||||||
 | 
					        d.update({...})
 | 
				
			||||||
 | 
					        return d
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    collector = _FiltersCollector()
 | 
				
			||||||
 | 
					    collector.visit(func)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # additionally scan simple 'X = {...}' and 'X.update({...})' patterns,
 | 
				
			||||||
 | 
					    # and if 'return X' occurs, merge those dicts.
 | 
				
			||||||
 | 
					    name_dicts: Dict[str, List[Tuple[str, str]]] = {}
 | 
				
			||||||
 | 
					    returns: List[str] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for n in ast.walk(func):
 | 
				
			||||||
 | 
					        if isinstance(n, ast.Assign):
 | 
				
			||||||
 | 
					            # X = { ... }
 | 
				
			||||||
 | 
					            if len(n.targets) == 1 and isinstance(n.targets[0], ast.Name):
 | 
				
			||||||
 | 
					                tgt = n.targets[0].id
 | 
				
			||||||
 | 
					                pairs = _FiltersCollector()._extract_mapping(n.value)
 | 
				
			||||||
 | 
					                if pairs:
 | 
				
			||||||
 | 
					                    name_dicts.setdefault(tgt, []).extend(pairs)
 | 
				
			||||||
 | 
					        elif isinstance(n, ast.Call):
 | 
				
			||||||
 | 
					            # X.update({ ... })
 | 
				
			||||||
 | 
					            if isinstance(n.func, ast.Attribute) and n.func.attr == "update":
 | 
				
			||||||
 | 
					                obj = n.func.value
 | 
				
			||||||
 | 
					                if isinstance(obj, ast.Name):
 | 
				
			||||||
 | 
					                    add_pairs = _FiltersCollector()._extract_mapping(n.args[0] if n.args else None)
 | 
				
			||||||
 | 
					                    if add_pairs:
 | 
				
			||||||
 | 
					                        name_dicts.setdefault(obj.id, []).extend(add_pairs)
 | 
				
			||||||
 | 
					        elif isinstance(n, ast.Return) and isinstance(n.value, ast.Name):
 | 
				
			||||||
 | 
					            returns.append(n.value.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for rname in returns:
 | 
				
			||||||
 | 
					        for p in name_dicts.get(rname, []):
 | 
				
			||||||
 | 
					            collector.defs.append(p)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # dedupe
 | 
				
			||||||
 | 
					    seen = set()
 | 
				
			||||||
 | 
					    out: List[Tuple[str, str]] = []
 | 
				
			||||||
 | 
					    for k, v in collector.defs:
 | 
				
			||||||
 | 
					        if (k, v) not in seen:
 | 
				
			||||||
 | 
					            seen.add((k, v))
 | 
				
			||||||
 | 
					            out.append((k, v))
 | 
				
			||||||
 | 
					    return out
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _ast_collect_filters_from_file(path: str) -> List[Tuple[str, str, str]]:
 | 
				
			||||||
 | 
					    code = _read(path)
 | 
				
			||||||
 | 
					    if not code:
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        tree = ast.parse(code, filename=path)
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    results: List[Tuple[str, str, str]] = []
 | 
				
			||||||
 | 
					    for node in tree.body:
 | 
				
			||||||
 | 
					        if isinstance(node, ast.ClassDef) and node.name == "FilterModule":
 | 
				
			||||||
 | 
					            for item in node.body:
 | 
				
			||||||
 | 
					                if isinstance(item, ast.FunctionDef) and item.name == "filters":
 | 
				
			||||||
 | 
					                    for (fname, callname) in _collect_filters_from_filters_method(item):
 | 
				
			||||||
 | 
					                        results.append((fname, callname, path))
 | 
				
			||||||
 | 
					    return results
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def collect_defined_filters() -> List[Dict[str, str]]:
 | 
				
			||||||
 | 
					    found: List[Dict[str, str]] = []
 | 
				
			||||||
 | 
					    for base in FILTER_PLUGIN_BASES:
 | 
				
			||||||
 | 
					        for path in _iter_files(base, py_only=True):
 | 
				
			||||||
 | 
					            if not _is_filter_plugins_dir(path):
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            for (filter_name, callable_name, fpath) in _ast_collect_filters_from_file(path):
 | 
				
			||||||
 | 
					                found.append({"filter": filter_name, "callable": callable_name, "file": fpath})
 | 
				
			||||||
 | 
					    return found
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					# Usage detection
 | 
				
			||||||
 | 
					# ---------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _compile_jinja_patterns(name: str) -> list[re.Pattern]:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Build robust patterns that match Jinja filter usage without using '%' string formatting.
 | 
				
			||||||
 | 
					    Handles:
 | 
				
			||||||
 | 
					      - {{ ... | name }}
 | 
				
			||||||
 | 
					      - {% ... | name %}
 | 
				
			||||||
 | 
					      - {% filter name %}...{% endfilter %}
 | 
				
			||||||
 | 
					      - bare YAML/Jinja like: when: x | name
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    escaped = re.escape(name)
 | 
				
			||||||
 | 
					    return [
 | 
				
			||||||
 | 
					        re.compile(r"\{\{[^}]*\|\s*" + escaped + r"\b", re.DOTALL),   # {{ ... | name }}
 | 
				
			||||||
 | 
					        re.compile(r"\{%\s*[^%]*\|\s*" + escaped + r"\b", re.DOTALL), # {% ... | name %}
 | 
				
			||||||
 | 
					        re.compile(r"\{%\s*filter\s+" + escaped + r"\b"),             # {% filter name %}
 | 
				
			||||||
 | 
					        re.compile(r"\|\s*" + escaped + r"\b"),                       # bare: when: x | name
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _python_call_pattern(callable_name: str) -> Optional[re.Pattern]:
 | 
				
			||||||
 | 
					    if not callable_name:
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					    return re.compile(r"\b%s\s*\(" % re.escape(callable_name))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def search_usage(filter_name: str, callable_name: str, *, skip_file: str) -> tuple[bool, bool]:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Search for filter usage.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Returns tuple:
 | 
				
			||||||
 | 
					      (used_anywhere, used_outside_tests)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    - used_anywhere: True if found in repo at all
 | 
				
			||||||
 | 
					    - used_outside_tests: True if found outside tests/
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    jinja_pats = _compile_jinja_patterns(filter_name)
 | 
				
			||||||
 | 
					    py_pat = _python_call_pattern(callable_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    used_anywhere = False
 | 
				
			||||||
 | 
					    used_outside_tests = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for base in SEARCH_BASES:
 | 
				
			||||||
 | 
					        for path in _iter_files(base, py_only=False):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                if os.path.samefile(path, skip_file):
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            content = _read(path)
 | 
				
			||||||
 | 
					            if not content:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            hit = False
 | 
				
			||||||
 | 
					            for pat in jinja_pats:
 | 
				
			||||||
 | 
					                if pat.search(content):
 | 
				
			||||||
 | 
					                    hit = True
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if not hit and py_pat and path.endswith(".py") and py_pat.search(content):
 | 
				
			||||||
 | 
					                hit = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if hit:
 | 
				
			||||||
 | 
					                used_anywhere = True
 | 
				
			||||||
 | 
					                if "/tests/" not in path and not path.endswith("tests"):
 | 
				
			||||||
 | 
					                    used_outside_tests = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return used_anywhere, used_outside_tests
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TestFilterDefinitionsAreUsed(unittest.TestCase):
 | 
				
			||||||
 | 
					    def test_every_defined_filter_is_used(self):
 | 
				
			||||||
 | 
					        definitions = collect_defined_filters()
 | 
				
			||||||
 | 
					        if not definitions:
 | 
				
			||||||
 | 
					            self.skipTest("No filters found under filter_plugins/.")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        unused = []
 | 
				
			||||||
 | 
					        for d in definitions:
 | 
				
			||||||
 | 
					            f_name, c_name, f_path = d["filter"], d["callable"], d["file"]
 | 
				
			||||||
 | 
					            used_any, used_outside = search_usage(f_name, c_name, skip_file=f_path)
 | 
				
			||||||
 | 
					            if not used_any:
 | 
				
			||||||
 | 
					                unused.append((f_name, c_name, f_path, "not used anywhere"))
 | 
				
			||||||
 | 
					            elif not used_outside:
 | 
				
			||||||
 | 
					                unused.append((f_name, c_name, f_path, "only used in tests"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if unused:
 | 
				
			||||||
 | 
					            msg = ["The following filters are invalidly unused:"]
 | 
				
			||||||
 | 
					            for f, c, p, reason in sorted(unused):
 | 
				
			||||||
 | 
					                msg.append(f"- '{f}' (callable '{c or 'unknown'}') defined in {p} → {reason}")
 | 
				
			||||||
 | 
					            self.fail("\n".join(msg))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    unittest.main()
 | 
				
			||||||
@@ -1,111 +0,0 @@
 | 
				
			|||||||
import os
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
import unittest
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Add the filter_plugins directory to the import path
 | 
					 | 
				
			||||||
dir_path = os.path.abspath(
 | 
					 | 
				
			||||||
    os.path.join(os.path.dirname(__file__), '../../../filter_plugins')
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
sys.path.insert(0, dir_path)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ansible.errors import AnsibleFilterError
 | 
					 | 
				
			||||||
from alias_domains_map import FilterModule
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestDomainFilters(unittest.TestCase):
 | 
					 | 
				
			||||||
    def setUp(self):
 | 
					 | 
				
			||||||
        self.filter_module = FilterModule()
 | 
					 | 
				
			||||||
        # Sample primary domain
 | 
					 | 
				
			||||||
        self.primary = 'example.com'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_alias_empty_apps(self):
 | 
					 | 
				
			||||||
        apps = {}
 | 
					 | 
				
			||||||
        expected = {}
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertEqual(result, expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_alias_without_aliases_and_no_canonical(self):
 | 
					 | 
				
			||||||
        apps = {'app1': {}}
 | 
					 | 
				
			||||||
        # canonical defaults to ['app1.example.com'], so alias should be []
 | 
					 | 
				
			||||||
        expected = {'app1': []}
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertEqual(result, expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_alias_with_explicit_aliases(self):
 | 
					 | 
				
			||||||
        apps = {
 | 
					 | 
				
			||||||
            'app1': {
 | 
					 | 
				
			||||||
                'server':{
 | 
					 | 
				
			||||||
                    'domains': {'aliases': ['alias.com']}
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        # canonical defaults to ['app1.example.com'], so alias should include alias.com and default
 | 
					 | 
				
			||||||
        expected = {'app1': ['alias.com', 'app1.example.com']}
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertCountEqual(result['app1'], expected['app1'])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_alias_with_canonical_not_default(self):
 | 
					 | 
				
			||||||
        apps = {
 | 
					 | 
				
			||||||
            'app1': {
 | 
					 | 
				
			||||||
                'server':{'domains': {'canonical': ['foo.com']}}
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        # foo.com is canonical, default not in canonical so added as alias
 | 
					 | 
				
			||||||
        expected = {'app1': ['app1.example.com']}
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertEqual(result, expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_alias_with_existing_default(self):
 | 
					 | 
				
			||||||
        apps = {
 | 
					 | 
				
			||||||
            'app1': {
 | 
					 | 
				
			||||||
                'server':{
 | 
					 | 
				
			||||||
                    'domains': {
 | 
					 | 
				
			||||||
                        'canonical': ['foo.com'],
 | 
					 | 
				
			||||||
                        'aliases': ['app1.example.com']
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        # default present in aliases, should not be duplicated
 | 
					 | 
				
			||||||
        expected = {'app1': ['app1.example.com']}
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertEqual(result, expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_invalid_aliases_type(self):
 | 
					 | 
				
			||||||
        apps = {
 | 
					 | 
				
			||||||
            'app1': {'server':{'domains': {'aliases': 123}}}
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        with self.assertRaises(AnsibleFilterError):
 | 
					 | 
				
			||||||
            self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_alias_with_empty_domains_cfg(self):
 | 
					 | 
				
			||||||
        apps = {
 | 
					 | 
				
			||||||
            'app1': {
 | 
					 | 
				
			||||||
                'server':{
 | 
					 | 
				
			||||||
                    'domains': {}
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        expected = apps
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertEqual(result, expected)
 | 
					 | 
				
			||||||
        
 | 
					 | 
				
			||||||
    def test_alias_with_canonical_dict_not_default(self):
 | 
					 | 
				
			||||||
        apps = {
 | 
					 | 
				
			||||||
            'app1': {
 | 
					 | 
				
			||||||
                'server':{
 | 
					 | 
				
			||||||
                    'domains': {
 | 
					 | 
				
			||||||
                        'canonical': {
 | 
					 | 
				
			||||||
                            'one': 'one.com',
 | 
					 | 
				
			||||||
                            'two': 'two.com'
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        expected = {'app1': ['app1.example.com']}
 | 
					 | 
				
			||||||
        result = self.filter_module.alias_domains_map(apps, self.primary)
 | 
					 | 
				
			||||||
        self.assertEqual(result, expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == "__main__":
 | 
					 | 
				
			||||||
    unittest.main()
 | 
					 | 
				
			||||||
@@ -1,63 +0,0 @@
 | 
				
			|||||||
# 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()
 | 
					 | 
				
			||||||
@@ -1,122 +0,0 @@
 | 
				
			|||||||
import os
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
import unittest
 | 
					 | 
				
			||||||
from unittest.mock import patch, mock_open
 | 
					 | 
				
			||||||
from ansible.errors import AnsibleFilterError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# make sure our plugin is on PYTHONPATH
 | 
					 | 
				
			||||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
 | 
					 | 
				
			||||||
sys.path.insert(0, root)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import load_configuration
 | 
					 | 
				
			||||||
from load_configuration import FilterModule, _cfg_cache
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestLoadConfigurationFilter(unittest.TestCase):
 | 
					 | 
				
			||||||
    def setUp(self):
 | 
					 | 
				
			||||||
        _cfg_cache.clear()
 | 
					 | 
				
			||||||
        self.f = FilterModule().filters()['load_configuration']
 | 
					 | 
				
			||||||
        self.app = 'html'
 | 
					 | 
				
			||||||
        self.nested_cfg = {
 | 
					 | 
				
			||||||
            'html': {
 | 
					 | 
				
			||||||
                'features': {'matomo': True},
 | 
					 | 
				
			||||||
                'server': {
 | 
					 | 
				
			||||||
                    'domains':{'canonical': ['html.example.com']}
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        self.flat_cfg = {
 | 
					 | 
				
			||||||
            'features': {'matomo': False},
 | 
					 | 
				
			||||||
            'server': {'domains':{'canonical': ['flat.example.com']}}
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_invalid_key(self):
 | 
					 | 
				
			||||||
        with self.assertRaises(AnsibleFilterError):
 | 
					 | 
				
			||||||
            self.f(self.app, None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.isdir', return_value=False)
 | 
					 | 
				
			||||||
    def test_no_roles_dir(self, _):
 | 
					 | 
				
			||||||
        with self.assertRaises(AnsibleFilterError):
 | 
					 | 
				
			||||||
            self.f(self.app, 'features.matomo')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @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=False)
 | 
					 | 
				
			||||||
    def test_no_matching_role(self, *_):
 | 
					 | 
				
			||||||
        self.assertIsNone(self.f(self.app, 'features.matomo'))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.listdir', return_value=['r1'])
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.isdir', return_value=True)
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.exists')
 | 
					 | 
				
			||||||
    @patch('load_configuration.open', new_callable=mock_open)
 | 
					 | 
				
			||||||
    @patch('load_configuration.yaml.safe_load')
 | 
					 | 
				
			||||||
    def test_primary_missing_conf(self, mock_yaml, mock_file, mock_exists, *_):
 | 
					 | 
				
			||||||
        mock_exists.side_effect = lambda p: p.endswith('vars/main.yml')
 | 
					 | 
				
			||||||
        mock_yaml.return_value = {'application_id': self.app}
 | 
					 | 
				
			||||||
        with self.assertRaises(AnsibleFilterError):
 | 
					 | 
				
			||||||
            self.f(self.app, 'features.matomo')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.listdir', return_value=['r1'])
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.isdir', return_value=True)
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.exists')
 | 
					 | 
				
			||||||
    @patch('load_configuration.open', new_callable=mock_open)
 | 
					 | 
				
			||||||
    @patch('load_configuration.yaml.safe_load')
 | 
					 | 
				
			||||||
    def test_primary_and_cache(self, mock_yaml, mock_file, mock_exists, *_):
 | 
					 | 
				
			||||||
        mock_exists.side_effect = lambda p: p.endswith('vars/main.yml') or p.endswith('config/main.yml')
 | 
					 | 
				
			||||||
        mock_yaml.side_effect = [
 | 
					 | 
				
			||||||
            {'application_id': self.app},  # main.yml
 | 
					 | 
				
			||||||
            self.nested_cfg              # config/main.yml
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
        # first load
 | 
					 | 
				
			||||||
        self.assertTrue(self.f(self.app, 'features.matomo'))
 | 
					 | 
				
			||||||
        self.assertIn(self.app, _cfg_cache)
 | 
					 | 
				
			||||||
        mock_yaml.reset_mock()
 | 
					 | 
				
			||||||
        # from cache
 | 
					 | 
				
			||||||
        self.assertEqual(self.f(self.app, 'server.domains.canonical'),
 | 
					 | 
				
			||||||
                         ['html.example.com'])
 | 
					 | 
				
			||||||
        mock_yaml.assert_not_called()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @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: {}"))
 | 
					 | 
				
			||||||
    @patch('load_configuration.yaml.safe_load', return_value={'html': {}})
 | 
					 | 
				
			||||||
    def test_key_not_found_after_load(self, *_):
 | 
					 | 
				
			||||||
        with self.assertRaises(AnsibleFilterError):
 | 
					 | 
				
			||||||
            self.f(self.app, 'does.not.exist')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.listdir', return_value=['r2'])
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.isdir', return_value=True)
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.exists')
 | 
					 | 
				
			||||||
    @patch('load_configuration.open', new_callable=mock_open)
 | 
					 | 
				
			||||||
    @patch('load_configuration.yaml.safe_load')
 | 
					 | 
				
			||||||
    def test_fallback_nested(self, mock_yaml, mock_file, mock_exists, *_):
 | 
					 | 
				
			||||||
        mock_exists.side_effect = lambda p: p.endswith('config/main.yml')
 | 
					 | 
				
			||||||
        mock_yaml.return_value = self.nested_cfg
 | 
					 | 
				
			||||||
        # nested fallback must work
 | 
					 | 
				
			||||||
        self.assertTrue(self.f(self.app, 'features.matomo'))
 | 
					 | 
				
			||||||
        self.assertEqual(self.f(self.app, 'server.domains.canonical'),
 | 
					 | 
				
			||||||
                         ['html.example.com'])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.listdir', return_value=['r4'])
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.isdir', return_value=True)
 | 
					 | 
				
			||||||
    @patch('load_configuration.os.path.exists')
 | 
					 | 
				
			||||||
    @patch('load_configuration.open', new_callable=mock_open)
 | 
					 | 
				
			||||||
    @patch('load_configuration.yaml.safe_load')
 | 
					 | 
				
			||||||
    def test_fallback_with_indexed_key(self, mock_yaml, mock_file, mock_exists, *_):
 | 
					 | 
				
			||||||
        # Testing with an indexed key like domains.canonical[0]
 | 
					 | 
				
			||||||
        mock_exists.side_effect = lambda p: p.endswith('config/main.yml')
 | 
					 | 
				
			||||||
        mock_yaml.return_value = {
 | 
					 | 
				
			||||||
            'file': {
 | 
					 | 
				
			||||||
                'server': {
 | 
					 | 
				
			||||||
                    'domains':{
 | 
					 | 
				
			||||||
                        'canonical': ['files.example.com', 'extra.example.com']
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        # should get the first element of the canonical domains list
 | 
					 | 
				
			||||||
        self.assertEqual(self.f('file', 'server.domains.canonical[0]'),
 | 
					 | 
				
			||||||
                         'files.example.com')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == '__main__':
 | 
					 | 
				
			||||||
    unittest.main()
 | 
					 | 
				
			||||||
@@ -1,56 +0,0 @@
 | 
				
			|||||||
import unittest
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
import os
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Ensure filter_plugins directory is on the path
 | 
					 | 
				
			||||||
sys.path.insert(
 | 
					 | 
				
			||||||
    0,
 | 
					 | 
				
			||||||
    os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from safe_join import safe_join
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestSafeJoinFilter(unittest.TestCase):
 | 
					 | 
				
			||||||
    def test_join_with_trailing_slashes(self):
 | 
					 | 
				
			||||||
        self.assertEqual(
 | 
					 | 
				
			||||||
            safe_join('http://example.com/', '/path/to'),
 | 
					 | 
				
			||||||
            'http://example.com/path/to'
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_join_without_slashes(self):
 | 
					 | 
				
			||||||
        self.assertEqual(
 | 
					 | 
				
			||||||
            safe_join('http://example.com', 'path/to'),
 | 
					 | 
				
			||||||
            'http://example.com/path/to'
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_base_none(self):
 | 
					 | 
				
			||||||
        with self.assertRaises(ValueError):
 | 
					 | 
				
			||||||
            safe_join(None, 'path')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_tail_none(self):
 | 
					 | 
				
			||||||
        with self.assertRaises(ValueError):
 | 
					 | 
				
			||||||
            safe_join('http://example.com', None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_base_empty(self):
 | 
					 | 
				
			||||||
        self.assertEqual(safe_join('', 'path'), '/path')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_tail_empty(self):
 | 
					 | 
				
			||||||
        # joining with empty tail should yield base with trailing slash
 | 
					 | 
				
			||||||
        self.assertEqual(
 | 
					 | 
				
			||||||
            safe_join('http://example.com', ''),
 | 
					 | 
				
			||||||
            'http://example.com/'
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_numeric_base(self):
 | 
					 | 
				
			||||||
        # numeric base is cast to string
 | 
					 | 
				
			||||||
        self.assertEqual(safe_join(123, 'path'), '123/path')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_exception_in_str(self):
 | 
					 | 
				
			||||||
        class Bad:
 | 
					 | 
				
			||||||
            def __str__(self):
 | 
					 | 
				
			||||||
                raise ValueError('bad')
 | 
					 | 
				
			||||||
        # on exception, safe_join returns ''
 | 
					 | 
				
			||||||
        self.assertEqual(safe_join(Bad(), 'x'), '')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == '__main__':
 | 
					 | 
				
			||||||
    unittest.main()
 | 
					 | 
				
			||||||
@@ -1,59 +0,0 @@
 | 
				
			|||||||
import unittest
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
import os
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Ensure filter_plugins directory is on the path
 | 
					 | 
				
			||||||
sys.path.insert(
 | 
					 | 
				
			||||||
    0,
 | 
					 | 
				
			||||||
    os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from safe import safe_placeholders
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestSafePlaceholdersFilter(unittest.TestCase):
 | 
					 | 
				
			||||||
    def test_simple_replacement(self):
 | 
					 | 
				
			||||||
        template = "Hello, {user}!"
 | 
					 | 
				
			||||||
        mapping = {'user': 'Alice'}
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, mapping), "Hello, Alice!")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_missing_placeholder(self):
 | 
					 | 
				
			||||||
        template = "Hello, {user}!"
 | 
					 | 
				
			||||||
        # Missing placeholder should raise KeyError
 | 
					 | 
				
			||||||
        with self.assertRaises(KeyError):
 | 
					 | 
				
			||||||
            safe_placeholders(template, {})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_none_template(self):
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(None, {'user': 'Alice'}), "")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_no_placeholders(self):
 | 
					 | 
				
			||||||
        template = "Just a plain string"
 | 
					 | 
				
			||||||
        mapping = {'any': 'value'}
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, mapping), "Just a plain string")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_multiple_placeholders(self):
 | 
					 | 
				
			||||||
        template = "{greet}, {user}!"
 | 
					 | 
				
			||||||
        mapping = {'greet': 'Hi', 'user': 'Bob'}
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, mapping), "Hi, Bob!")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_numeric_values(self):
 | 
					 | 
				
			||||||
        template = "Count: {n}"
 | 
					 | 
				
			||||||
        mapping = {'n': 0}
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, mapping), "Count: 0")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_extra_mapping_keys(self):
 | 
					 | 
				
			||||||
        template = "Value: {a}"
 | 
					 | 
				
			||||||
        mapping = {'a': '1', 'b': '2'}
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, mapping), "Value: 1")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_malformed_template(self):
 | 
					 | 
				
			||||||
        # Unclosed placeholder should be caught and return empty string
 | 
					 | 
				
			||||||
        template = "Unclosed {key"
 | 
					 | 
				
			||||||
        mapping = {'key': 'value'}
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, mapping), "")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_mapping_none(self):
 | 
					 | 
				
			||||||
        template = "Test {x}"
 | 
					 | 
				
			||||||
        self.assertEqual(safe_placeholders(template, None), "")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == '__main__':
 | 
					 | 
				
			||||||
    unittest.main()
 | 
					 | 
				
			||||||
@@ -1,44 +0,0 @@
 | 
				
			|||||||
import unittest
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
import os
 | 
					 | 
				
			||||||
from jinja2 import Undefined
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Ensure filter_plugins directory is on the path
 | 
					 | 
				
			||||||
sys.path.insert(
 | 
					 | 
				
			||||||
    0,
 | 
					 | 
				
			||||||
    os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../filter_plugins'))
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from safe import FilterModule
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestSafeVarFilter(unittest.TestCase):
 | 
					 | 
				
			||||||
    def setUp(self):
 | 
					 | 
				
			||||||
        # Retrieve the safe_var filter function
 | 
					 | 
				
			||||||
        self.filter = FilterModule().filters()['safe_var']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_returns_non_empty_string(self):
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter('hello'), 'hello')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_returns_empty_string(self):
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter(''), '')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_returns_empty_for_none(self):
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter(None), '')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_returns_empty_for_jinja_undefined(self):
 | 
					 | 
				
			||||||
        # Instantiate an Undefined without arguments
 | 
					 | 
				
			||||||
        undef = Undefined()
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter(undef), '')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_returns_zero_for_zero(self):
 | 
					 | 
				
			||||||
        # 0 is falsey but not None or Undefined, so safe_var returns it
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter(0), 0)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_returns_list_and_dict_unchanged(self):
 | 
					 | 
				
			||||||
        data = {'key': 'value'}
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter(data), data)
 | 
					 | 
				
			||||||
        lst = [1, 2, 3]
 | 
					 | 
				
			||||||
        self.assertEqual(self.filter(lst), lst)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == '__main__':
 | 
					 | 
				
			||||||
    unittest.main()
 | 
					 | 
				
			||||||
@@ -23,7 +23,6 @@ class TestDockerCardsLookup(unittest.TestCase):
 | 
				
			|||||||
        os.makedirs(os.path.join(self.role_dir, "meta"))
 | 
					        os.makedirs(os.path.join(self.role_dir, "meta"))
 | 
				
			||||||
        os.makedirs(os.path.join(self.role_dir, "vars"))
 | 
					        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")
 | 
					        vars_main = os.path.join(self.role_dir, "vars", "main.yml")
 | 
				
			||||||
        with open(vars_main, "w", encoding="utf-8") as f:
 | 
					        with open(vars_main, "w", encoding="utf-8") as f:
 | 
				
			||||||
            f.write("application_id: portfolio\n")
 | 
					            f.write("application_id: portfolio\n")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -77,12 +77,6 @@ class SplitPostgresConnectionsTests(unittest.TestCase):
 | 
				
			|||||||
    def test_registry_contains_filters(self):
 | 
					    def test_registry_contains_filters(self):
 | 
				
			||||||
        registry = self.mod.FilterModule().filters()
 | 
					        registry = self.mod.FilterModule().filters()
 | 
				
			||||||
        self.assertIn("split_postgres_connections", registry)
 | 
					        self.assertIn("split_postgres_connections", registry)
 | 
				
			||||||
        self.assertIn("list_postgres_roles", registry)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_list_postgres_roles(self):
 | 
					 | 
				
			||||||
        roles = self.mod.list_postgres_roles(self.roles_dir)
 | 
					 | 
				
			||||||
        self.assertIsInstance(roles, list)
 | 
					 | 
				
			||||||
        self.assertSetEqual(set(roles), {"app_a", "app_b"})
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_split_postgres_connections_division(self):
 | 
					    def test_split_postgres_connections_division(self):
 | 
				
			||||||
        # There are 2 postgres roles -> 200 / 2 = 100
 | 
					        # There are 2 postgres roles -> 200 / 2 = 100
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,61 +0,0 @@
 | 
				
			|||||||
import unittest
 | 
					 | 
				
			||||||
import os
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Add the path to roles/sys-ctl-bkp-docker-2-loc/filter_plugins
 | 
					 | 
				
			||||||
CURRENT_DIR = os.path.dirname(__file__)
 | 
					 | 
				
			||||||
FILTER_PLUGIN_DIR = os.path.abspath(
 | 
					 | 
				
			||||||
    os.path.join(CURRENT_DIR, '../../../../../roles/sys-ctl-bkp-docker-2-loc/filter_plugins')
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
sys.path.insert(0, FILTER_PLUGIN_DIR)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from dict_to_cli_args import dict_to_cli_args
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class TestDictToCliArgs(unittest.TestCase):
 | 
					 | 
				
			||||||
    def test_simple_string_args(self):
 | 
					 | 
				
			||||||
        data = {"backup-dir": "/mnt/backups", "version-suffix": "-nightly"}
 | 
					 | 
				
			||||||
        expected = "--backup-dir=/mnt/backups --version-suffix=-nightly"
 | 
					 | 
				
			||||||
        self.assertEqual(dict_to_cli_args(data), expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_boolean_true(self):
 | 
					 | 
				
			||||||
        data = {"shutdown": True, "everything": True}
 | 
					 | 
				
			||||||
        expected = "--shutdown --everything"
 | 
					 | 
				
			||||||
        self.assertEqual(dict_to_cli_args(data), expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_boolean_false(self):
 | 
					 | 
				
			||||||
        data = {"shutdown": False, "everything": True}
 | 
					 | 
				
			||||||
        expected = "--everything"
 | 
					 | 
				
			||||||
        self.assertEqual(dict_to_cli_args(data), expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_list_argument(self):
 | 
					 | 
				
			||||||
        data = {"ignore-volumes": ["redis", "memcached"]}
 | 
					 | 
				
			||||||
        expected = '--ignore-volumes="redis memcached"'
 | 
					 | 
				
			||||||
        self.assertEqual(dict_to_cli_args(data), expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_mixed_arguments(self):
 | 
					 | 
				
			||||||
        data = {
 | 
					 | 
				
			||||||
            "backup-dir": "/mnt/backups",
 | 
					 | 
				
			||||||
            "shutdown": True,
 | 
					 | 
				
			||||||
            "ignore-volumes": ["redis", "memcached"]
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        result = dict_to_cli_args(data)
 | 
					 | 
				
			||||||
        self.assertIn("--backup-dir=/mnt/backups", result)
 | 
					 | 
				
			||||||
        self.assertIn("--shutdown", result)
 | 
					 | 
				
			||||||
        self.assertIn('--ignore-volumes="redis memcached"', result)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_empty_dict(self):
 | 
					 | 
				
			||||||
        self.assertEqual(dict_to_cli_args({}), "")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_none_value(self):
 | 
					 | 
				
			||||||
        data = {"some-value": None, "other": "yes"}
 | 
					 | 
				
			||||||
        expected = "--other=yes"
 | 
					 | 
				
			||||||
        self.assertEqual(dict_to_cli_args(data), expected)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def test_invalid_type(self):
 | 
					 | 
				
			||||||
        with self.assertRaises(TypeError):
 | 
					 | 
				
			||||||
            dict_to_cli_args(["not", "a", "dict"])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == "__main__":
 | 
					 | 
				
			||||||
    unittest.main()
 | 
					 | 
				
			||||||
		Reference in New Issue
	
	Block a user