mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-17 17:26:42 +02:00
Solved missing logout injection bug and refactored srv-web-7-7-inj-compose
This commit is contained in:
parent
5b64b47754
commit
3b4821f7e7
@ -9,7 +9,7 @@
|
|||||||
cf_zone_id: "{{ (cf_zone_ids | default({})).get(domain | to_primary_domain, false) }}"
|
cf_zone_id: "{{ (cf_zone_ids | default({})).get(domain | to_primary_domain, false) }}"
|
||||||
|
|
||||||
# Only look up from Cloudflare if we still don't have it
|
# Only look up from Cloudflare if we still don't have it
|
||||||
- name: "Ensure Cloudflare Zone ID is known for {{ domain }}"
|
- name: "Ensure Cloudflare Zone ID is known for '{{ domain }}'"
|
||||||
vars:
|
vars:
|
||||||
cf_api_url: "https://api.cloudflare.com/client/v4/zones"
|
cf_api_url: "https://api.cloudflare.com/client/v4/zones"
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
- name: "Check if certificate already exists for {{ domain }}"
|
- name: "Check if certificate already exists for '{{ domain }}'"
|
||||||
cert_check_exists:
|
cert_check_exists:
|
||||||
domain: "{{ domain }}"
|
domain: "{{ domain }}"
|
||||||
cert_base_path: "{{ LETSENCRYPT_LIVE_PATH }}"
|
cert_base_path: "{{ LETSENCRYPT_LIVE_PATH }}"
|
||||||
register: cert_check
|
register: cert_check
|
||||||
|
|
||||||
- name: "receive certificate for {{ domain }}"
|
- name: "receive certificate for '{{ domain }}'"
|
||||||
command: >-
|
command: >-
|
||||||
certbot certonly
|
certbot certonly
|
||||||
--agree-tos
|
--agree-tos
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
- name: "Include flavor '{{ CERTBOT_FLAVOR }}' for '{{ domain }}'"
|
- name: "Include flavor '{{ CERTBOT_FLAVOR }}' for '{{ domain }}'"
|
||||||
include_tasks: "{{ role_path }}/tasks/flavors/{{ CERTBOT_FLAVOR }}.yml"
|
include_tasks: "{{ role_path }}/tasks/flavors/{{ CERTBOT_FLAVOR }}.yml"
|
||||||
|
|
||||||
#- name: "Cleanup dedicated cert for {{ domain }}"
|
#- name: "Cleanup dedicated cert for '{{ domain }}'"
|
||||||
# command: >-
|
# command: >-
|
||||||
# certbot delete --cert-name {{ domain }} --non-interactive
|
# certbot delete --cert-name {{ domain }} --non-interactive
|
||||||
# when:
|
# when:
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
# run_once_srv_web_7_6_composer: deactivated
|
# run_once_srv_web_7_6_composer: deactivated
|
||||||
|
|
||||||
- name: "include role srv-web-7-7-inj-compose for {{ domain }}"
|
- name: "include role srv-web-7-7-inj-compose for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-7-7-inj-compose
|
name: srv-web-7-7-inj-compose
|
||||||
|
|
||||||
- name: "include role srv-web-6-6-tls-core for {{ domain }}"
|
- name: "include role srv-web-6-6-tls-core for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-6-6-tls-core
|
name: srv-web-6-6-tls-core
|
||||||
|
0
roles/srv-web-7-7-inj-compose/__init__.py
Normal file
0
roles/srv-web-7-7-inj-compose/__init__.py
Normal file
35
roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py
Normal file
35
roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# roles/srv-web-7-7-inj-compose/filter_plugins/inj_enabled.py
|
||||||
|
#
|
||||||
|
# Usage in tasks:
|
||||||
|
# - set_fact:
|
||||||
|
# inj_enabled: "{{ applications | inj_enabled(application_id, ['javascript','logout','css','matomo','desktop']) }}"
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# allow imports from module_utils (same trick as your get_app_conf filter)
|
||||||
|
base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||||
|
mu = os.path.join(base, 'module_utils')
|
||||||
|
for p in (base, mu):
|
||||||
|
if p not in sys.path:
|
||||||
|
sys.path.insert(0, p)
|
||||||
|
|
||||||
|
from module_utils.config_utils import get_app_conf
|
||||||
|
|
||||||
|
def inj_enabled_filter(applications, application_id, features, prefix="features", default=False):
|
||||||
|
"""
|
||||||
|
Build a dict {feature: value} by reading the feature flags under the given prefix for the selected application.
|
||||||
|
Uses get_app_conf with strict=False so missing keys just return the default.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for f in features:
|
||||||
|
path = f"{prefix}.{f}" if prefix else f
|
||||||
|
result[f] = get_app_conf(applications, application_id, path, strict=False, default=default)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"inj_enabled": inj_enabled_filter,
|
||||||
|
}
|
@ -1,11 +1,6 @@
|
|||||||
- name: Set inj_enabled dictionary
|
- name: Build inj_enabled
|
||||||
set_fact:
|
set_fact:
|
||||||
inj_enabled:
|
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
|
||||||
javascript: "{{ applications | get_app_conf(application_id, 'features.javascript', False) }}"
|
|
||||||
logout: "{{ (applications | get_app_conf(application_id, 'features.logout', False) or domain == PRIMARY_DOMAIN) }}"
|
|
||||||
css: "{{ applications | get_app_conf(application_id, 'features.css', False) }}"
|
|
||||||
matomo: "{{ applications | get_app_conf(application_id, 'features.matomo', False) }}"
|
|
||||||
desktop: "{{ applications | get_app_conf(application_id, 'features.desktop', False) }}"
|
|
||||||
|
|
||||||
- block:
|
- block:
|
||||||
- name: Include dependency 'srv-web-7-4-core'
|
- name: Include dependency 'srv-web-7-4-core'
|
||||||
@ -15,13 +10,13 @@
|
|||||||
- include_tasks: utils/run_once.yml
|
- include_tasks: utils/run_once.yml
|
||||||
when: run_once_srv_web_7_7_inj_compose is not defined
|
when: run_once_srv_web_7_7_inj_compose is not defined
|
||||||
|
|
||||||
- name: "Activate Portfolio iFrame notifier for {{ domain }}"
|
- name: "Activate Portfolio iFrame notifier for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-7-7-inj-desktop
|
name: srv-web-7-7-inj-desktop
|
||||||
public: true # Vars used in templates
|
public: true # Vars used in templates
|
||||||
when: inj_enabled.desktop
|
when: inj_enabled.desktop
|
||||||
|
|
||||||
- name: "Load CDN for {{ domain }}"
|
- name: "Load CDN for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: web-svc-cdn
|
name: web-svc-cdn
|
||||||
public: false
|
public: false
|
||||||
@ -41,24 +36,28 @@
|
|||||||
vars:
|
vars:
|
||||||
handler_role_name: "{{ item }}"
|
handler_role_name: "{{ item }}"
|
||||||
|
|
||||||
- name: "Activate Corporate CSS for {{ domain }}"
|
- name: Reinitialize 'inj_enabled' for '{{ domain }}', after modification by CDN
|
||||||
|
set_fact:
|
||||||
|
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
|
||||||
|
|
||||||
|
- name: "Activate Corporate CSS for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-7-7-inj-css
|
name: srv-web-7-7-inj-css
|
||||||
when:
|
when:
|
||||||
- inj_enabled.css
|
- inj_enabled.css
|
||||||
- run_once_srv_web_7_7_inj_css is not defined
|
- run_once_srv_web_7_7_inj_css is not defined
|
||||||
|
|
||||||
- name: "Activate Matomo Tracking for {{ domain }}"
|
- name: "Activate Matomo Tracking for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-7-7-inj-matomo
|
name: srv-web-7-7-inj-matomo
|
||||||
when: inj_enabled.matomo
|
when: inj_enabled.matomo
|
||||||
|
|
||||||
- name: "Activate Javascript for {{ domain }}"
|
- name: "Activate Javascript for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-7-7-inj-javascript
|
name: srv-web-7-7-inj-javascript
|
||||||
when: inj_enabled.javascript
|
when: inj_enabled.javascript
|
||||||
|
|
||||||
- name: "Activate logout proxy for {{ domain }}"
|
- name: "Activate logout proxy for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: srv-web-7-7-inj-logout
|
name: srv-web-7-7-inj-logout
|
||||||
public: true # Vars used in templates
|
public: true # Vars used in templates
|
||||||
|
@ -1,3 +1,17 @@
|
|||||||
|
{% macro push_snippets(list_name, features) -%}
|
||||||
|
{% for f in features -%}
|
||||||
|
{% if inj_enabled.get(f) -%}
|
||||||
|
{{ list_name }}[#{{ list_name }} + 1] = [=[
|
||||||
|
{%- include
|
||||||
|
'roles/srv-web-7-7-inj-' ~ f ~
|
||||||
|
'/templates/' ~
|
||||||
|
('head' if list_name == 'head_snippets' else 'body') ~
|
||||||
|
'_sub.j2'
|
||||||
|
-%}
|
||||||
|
]=]
|
||||||
|
{% endif -%}
|
||||||
|
{% endfor -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
lua_need_request_body on;
|
lua_need_request_body on;
|
||||||
|
|
||||||
@ -43,13 +57,7 @@ body_filter_by_lua_block {
|
|||||||
-- build a list of head-injection snippets
|
-- build a list of head-injection snippets
|
||||||
local head_snippets = {}
|
local head_snippets = {}
|
||||||
|
|
||||||
{% for head_feature in ['css', 'matomo', 'desktop', 'javascript', 'logout' ] %}
|
{{ push_snippets('head_snippets', ['css','matomo','desktop','javascript','logout']) }}
|
||||||
{% if applications | get_app_conf(application_id, 'features.' ~ head_feature, false) %}
|
|
||||||
head_snippets[#head_snippets + 1] = [=[
|
|
||||||
{%- include "roles/srv-web-7-7-inj-" ~ head_feature ~ "/templates/head_sub.j2" -%}
|
|
||||||
]=]
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
-- inject all collected snippets right before </head>
|
-- inject all collected snippets right before </head>
|
||||||
local head_payload = table.concat(head_snippets, "\n") .. "</head>"
|
local head_payload = table.concat(head_snippets, "\n") .. "</head>"
|
||||||
@ -58,13 +66,7 @@ body_filter_by_lua_block {
|
|||||||
-- build a list of body-injection snippets
|
-- build a list of body-injection snippets
|
||||||
local body_snippets = {}
|
local body_snippets = {}
|
||||||
|
|
||||||
{% for body_feature in ['matomo', 'logout', 'desktop'] %}
|
{{ push_snippets('body_snippets', ['matomo','logout','desktop']) }}
|
||||||
{% if applications | get_app_conf(application_id, 'features.' ~ body_feature, false) %}
|
|
||||||
body_snippets[#body_snippets + 1] = [=[
|
|
||||||
{%- include "roles/srv-web-7-7-inj-" ~ body_feature ~ "/templates/body_sub.j2" -%}
|
|
||||||
]=]
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
-- inject all collected snippets right before </body>
|
-- inject all collected snippets right before </body>
|
||||||
local body_payload = table.concat(body_snippets, "\n") .. "</body>"
|
local body_payload = table.concat(body_snippets, "\n") .. "</body>"
|
||||||
|
@ -1,2 +1,9 @@
|
|||||||
# Docker
|
# Docker
|
||||||
docker_pull_git_repository: false # Deactivated here to don't inhire this
|
docker_pull_git_repository: false # Deactivated here to don't inhire this
|
||||||
|
|
||||||
|
SRV_WEB_INJ_COMP_FEATURES_ALL:
|
||||||
|
- 'javascript'
|
||||||
|
- 'logout'
|
||||||
|
- 'css'
|
||||||
|
- 'matomo'
|
||||||
|
- 'desktop'
|
@ -37,7 +37,7 @@
|
|||||||
uri:
|
uri:
|
||||||
url: "{{ matomo_index_php_url }}"
|
url: "{{ matomo_index_php_url }}"
|
||||||
method: POST
|
method: POST
|
||||||
body: "module=API&method=SitesManager.addSite&siteName={{ base_domain }}&urls=https://{{ base_domain }}&token_auth={{ matomo_auth_token }}&format=json"
|
body: "module=API&method=SitesManager.addSite&siteName={{ base_domain }}&urls={{ WEB_PROTOCOL }}://{{ base_domain }}&token_auth={{ matomo_auth_token }}&format=json"
|
||||||
body_format: form-urlencoded
|
body_format: form-urlencoded
|
||||||
status_code: 200
|
status_code: 200
|
||||||
return_content: yes
|
return_content: yes
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
base_domain: "{{ domain | regex_replace('^(?:.*\\.)?(.+\\..+)$', '\\1') }}"
|
base_domain: "{{ domain | regex_replace('^(?:.*\\.)?(.+\\..+)$', '\\1') }}"
|
||||||
matomo_index_php_url: "{{ domains | get_url('web-app-matomo', WEB_PROTOCOL) }}/index.php"
|
matomo_index_php_url: "{{ domains | get_url('web-app-matomo', WEB_PROTOCOL) }}/index.php"
|
||||||
matomo_auth_token: "{{ applications['web-app-matomo'].credentials.auth_token }}"
|
matomo_auth_token: "{{ applications['web-app-matomo'].credentials.auth_token }}"
|
||||||
matomo_verification_url: "{{ matomo_index_php_url }}?module=API&method=SitesManager.getSitesIdFromSiteUrl&url=https://{{ base_domain }}&format=json&token_auth={{ matomo_auth_token }}"
|
matomo_verification_url: "{{ matomo_index_php_url }}?module=API&method=SitesManager.getSitesIdFromSiteUrl&url={{ WEB_PROTOCOL }}://{{ base_domain }}&format=json&token_auth={{ matomo_auth_token }}"
|
@ -22,3 +22,4 @@
|
|||||||
executable: /bin/bash
|
executable: /bin/bash
|
||||||
chdir: "{{ DISCOURSE_REPOSITORY_DIR }}"
|
chdir: "{{ DISCOURSE_REPOSITORY_DIR }}"
|
||||||
listen: recreate discourse
|
listen: recreate discourse
|
||||||
|
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
@ -33,11 +33,20 @@
|
|||||||
notify: recreate discourse
|
notify: recreate discourse
|
||||||
|
|
||||||
- name: "Verify that '{{ DISCOURSE_CONTAINER }}' is running"
|
- name: "Verify that '{{ DISCOURSE_CONTAINER }}' is running"
|
||||||
command: docker compose ps --filter status=running --format '{{"{{"}}.Name{{"}}"}}' | grep -x {{ DISCOURSE_CONTAINER }}
|
ansible.builtin.command:
|
||||||
register: docker_ps
|
argv:
|
||||||
changed_when: docker_ps.rc == 1
|
- docker
|
||||||
failed_when: docker_ps.rc not in [0, 1]
|
- ps
|
||||||
notify: recreate discourse
|
- --filter
|
||||||
|
- "name=^{{ DISCOURSE_CONTAINER }}$"
|
||||||
|
- --filter
|
||||||
|
- status=running
|
||||||
|
- --format
|
||||||
|
- "{{ '{{.Names}}' }}"
|
||||||
|
register: docker_ps
|
||||||
|
changed_when: docker_ps.stdout.strip() == ""
|
||||||
|
failed_when: docker_ps.rc != 0
|
||||||
|
notify: recreate discourse
|
||||||
|
|
||||||
- name: flush, to recreate discourse app
|
- name: flush, to recreate discourse app
|
||||||
meta: flush_handlers
|
meta: flush_handlers
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
include_role:
|
include_role:
|
||||||
name: srv-web-6-6-tls-core
|
name: srv-web-6-6-tls-core
|
||||||
|
|
||||||
- name: "Deploying NGINX redirect configuration for {{ domain }}"
|
- name: "Deploying NGINX redirect configuration for '{{ domain }}'"
|
||||||
template:
|
template:
|
||||||
src: redirect.domain.nginx.conf.j2
|
src: redirect.domain.nginx.conf.j2
|
||||||
dest: "{{ NGINX.DIRECTORIES.HTTP.SERVERS }}{{ domain }}.conf"
|
dest: "{{ NGINX.DIRECTORIES.HTTP.SERVERS }}{{ domain }}.conf"
|
||||||
|
@ -4,13 +4,24 @@ import yaml
|
|||||||
import re
|
import re
|
||||||
from glob import glob
|
from glob import glob
|
||||||
|
|
||||||
|
|
||||||
class TestVariableDefinitions(unittest.TestCase):
|
class TestVariableDefinitions(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Ensures that every Jinja2 variable used in templates/playbooks is defined
|
||||||
|
somewhere in the repository (direct var files, set_fact/vars blocks,
|
||||||
|
loop_var/register names, Jinja set/for definitions, and Jinja macro parameters).
|
||||||
|
|
||||||
|
If a variable is not defined, the test passes only if a corresponding
|
||||||
|
fallback key exists (either "default_<var>" or "defaults_<var>").
|
||||||
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# Project root
|
# Project root = repo root (tests/integration/.. -> ../../)
|
||||||
self.project_root = os.path.abspath(
|
self.project_root = os.path.abspath(
|
||||||
os.path.join(os.path.dirname(__file__), '../../')
|
os.path.join(os.path.dirname(__file__), '../../')
|
||||||
)
|
)
|
||||||
# Gather all definition files recursively under vars/ and defaults/, plus group_vars/all
|
|
||||||
|
# Collect all variable definition files: roles/*/{vars,defaults}/**/*.yml and group_vars/all/*.yml
|
||||||
self.var_files = []
|
self.var_files = []
|
||||||
patterns = [
|
patterns = [
|
||||||
os.path.join(self.project_root, 'roles', '*', 'vars', '**', '*.yml'),
|
os.path.join(self.project_root, 'roles', '*', 'vars', '**', '*.yml'),
|
||||||
@ -20,20 +31,41 @@ class TestVariableDefinitions(unittest.TestCase):
|
|||||||
for pat in patterns:
|
for pat in patterns:
|
||||||
self.var_files.extend(glob(pat, recursive=True))
|
self.var_files.extend(glob(pat, recursive=True))
|
||||||
|
|
||||||
# Valid file extensions to scan for definitions and usages
|
# File extensions to scan for Jinja usage/inline definitions
|
||||||
self.scan_extensions = {'.yml', '.j2'}
|
self.scan_extensions = {'.yml', '.j2'}
|
||||||
|
|
||||||
|
# -----------------------
|
||||||
# Regex patterns
|
# Regex patterns
|
||||||
|
# -----------------------
|
||||||
|
|
||||||
|
# Simple {{ var }} usage with optional Jinja filters after a pipe
|
||||||
self.simple_var_pattern = re.compile(r"{{\s*([a-zA-Z_]\w*)\s*(?:\|[^}]*)?}}")
|
self.simple_var_pattern = re.compile(r"{{\s*([a-zA-Z_]\w*)\s*(?:\|[^}]*)?}}")
|
||||||
|
|
||||||
|
# {% set var = ... %}
|
||||||
self.jinja_set_def = re.compile(r'{%\s*-?\s*set\s+([a-zA-Z_]\w*)\s*=')
|
self.jinja_set_def = re.compile(r'{%\s*-?\s*set\s+([a-zA-Z_]\w*)\s*=')
|
||||||
self.jinja_for_def = re.compile(r'{%\s*-?\s*for\s+([a-zA-Z_]\w*)(?:\s*,\s*([a-zA-Z_]\w*))?\s+in')
|
|
||||||
|
# {% for x in ... %} or {% for k, v in ... %}
|
||||||
|
self.jinja_for_def = re.compile(
|
||||||
|
r'{%\s*-?\s*for\s+([a-zA-Z_]\w*)(?:\s*,\s*([a-zA-Z_]\w*))?\s+in'
|
||||||
|
)
|
||||||
|
|
||||||
|
# {% macro name(param1, param2=..., *varargs, **kwargs) %}
|
||||||
|
self.jinja_macro_def = re.compile(
|
||||||
|
r'{%\s*-?\s*macro\s+[a-zA-Z_]\w*\s*\((.*?)\)\s*-?%}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ansible YAML anchors for inline var declarations
|
||||||
self.ansible_set_fact = re.compile(r'^(?:\s*[-]\s*)?set_fact\s*:\s*$')
|
self.ansible_set_fact = re.compile(r'^(?:\s*[-]\s*)?set_fact\s*:\s*$')
|
||||||
self.ansible_vars_block = re.compile(r'^(?:\s*[-]\s*)?vars\s*:\s*$')
|
self.ansible_vars_block = re.compile(r'^(?:\s*[-]\s*)?vars\s*:\s*$')
|
||||||
self.ansible_loop_var = re.compile(r'^\s*loop_var\s*:\s*([a-zA-Z_]\w*)')
|
self.ansible_loop_var = re.compile(r'^\s*loop_var\s*:\s*([a-zA-Z_]\w*)')
|
||||||
self.mapping_key = re.compile(r'^\s*([a-zA-Z_]\w*)\s*:\s*')
|
self.mapping_key = re.compile(r'^\s*([a-zA-Z_]\w*)\s*:\s*')
|
||||||
|
|
||||||
# Initialize defined set from var files
|
# -----------------------
|
||||||
|
# Collect "defined" names
|
||||||
|
# -----------------------
|
||||||
self.defined = set()
|
self.defined = set()
|
||||||
|
|
||||||
|
# 1) Keys from var files (top-level dict keys)
|
||||||
for vf in self.var_files:
|
for vf in self.var_files:
|
||||||
try:
|
try:
|
||||||
with open(vf, 'r', encoding='utf-8') as f:
|
with open(vf, 'r', encoding='utf-8') as f:
|
||||||
@ -41,9 +73,10 @@ class TestVariableDefinitions(unittest.TestCase):
|
|||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
self.defined.update(data.keys())
|
self.defined.update(data.keys())
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Ignore unreadable/invalid YAML files
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Phase 1: scan all files to collect inline definitions
|
# 2) Inline definitions across all scanned files
|
||||||
for root, _, files in os.walk(self.project_root):
|
for root, _, files in os.walk(self.project_root):
|
||||||
for fn in files:
|
for fn in files:
|
||||||
ext = os.path.splitext(fn)[1]
|
ext = os.path.splitext(fn)[1]
|
||||||
@ -51,91 +84,136 @@ class TestVariableDefinitions(unittest.TestCase):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
path = os.path.join(root, fn)
|
path = os.path.join(root, fn)
|
||||||
|
|
||||||
in_set_fact = False
|
in_set_fact = False
|
||||||
set_fact_indent = 0
|
set_fact_indent = 0
|
||||||
in_vars_block = False
|
in_vars_block = False
|
||||||
vars_block_indent = 0
|
vars_block_indent = 0
|
||||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
||||||
for line in f:
|
|
||||||
stripped = line.lstrip()
|
|
||||||
indent = len(line) - len(stripped)
|
|
||||||
# set_fact keys
|
|
||||||
if self.ansible_set_fact.match(stripped):
|
|
||||||
in_set_fact = True
|
|
||||||
set_fact_indent = indent
|
|
||||||
continue
|
|
||||||
if in_set_fact:
|
|
||||||
if indent > set_fact_indent and stripped.strip():
|
|
||||||
m = self.mapping_key.match(stripped)
|
|
||||||
if m:
|
|
||||||
self.defined.add(m.group(1))
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
in_set_fact = False
|
|
||||||
# vars block keys
|
|
||||||
if self.ansible_vars_block.match(stripped):
|
|
||||||
in_vars_block = True
|
|
||||||
vars_block_indent = indent
|
|
||||||
continue
|
|
||||||
if in_vars_block:
|
|
||||||
# skip blank lines within vars block
|
|
||||||
if not stripped.strip():
|
|
||||||
continue
|
|
||||||
if indent > vars_block_indent:
|
|
||||||
m = self.mapping_key.match(stripped)
|
|
||||||
if m:
|
|
||||||
self.defined.add(m.group(1))
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
in_vars_block = False
|
|
||||||
# loop_var
|
|
||||||
m_loop = self.ansible_loop_var.match(stripped)
|
|
||||||
if m_loop:
|
|
||||||
self.defined.add(m_loop.group(1))
|
|
||||||
|
|
||||||
# register
|
try:
|
||||||
m_reg = re.match(r'^\s*register\s*:\s*([a-zA-Z_]\w*)', stripped)
|
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
if m_reg:
|
for line in f:
|
||||||
self.defined.add(m_reg.group(1))
|
stripped = line.lstrip()
|
||||||
# jinja set
|
indent = len(line) - len(stripped)
|
||||||
for m in self.jinja_set_def.finditer(line):
|
|
||||||
self.defined.add(m.group(1))
|
# --- set_fact block keys
|
||||||
# jinja for
|
if self.ansible_set_fact.match(stripped):
|
||||||
for m in self.jinja_for_def.finditer(line):
|
in_set_fact = True
|
||||||
self.defined.add(m.group(1))
|
set_fact_indent = indent
|
||||||
if m.group(2):
|
continue
|
||||||
self.defined.add(m.group(2))
|
if in_set_fact:
|
||||||
|
# Still inside set_fact child mapping?
|
||||||
|
if indent > set_fact_indent and stripped.strip():
|
||||||
|
m = self.mapping_key.match(stripped)
|
||||||
|
if m:
|
||||||
|
self.defined.add(m.group(1))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
in_set_fact = False
|
||||||
|
|
||||||
|
# --- vars: block keys
|
||||||
|
if self.ansible_vars_block.match(stripped):
|
||||||
|
in_vars_block = True
|
||||||
|
vars_block_indent = indent
|
||||||
|
continue
|
||||||
|
if in_vars_block:
|
||||||
|
# Ignore blank lines inside vars block
|
||||||
|
if not stripped.strip():
|
||||||
|
continue
|
||||||
|
# Still inside vars child mapping?
|
||||||
|
if indent > vars_block_indent:
|
||||||
|
m = self.mapping_key.match(stripped)
|
||||||
|
if m:
|
||||||
|
self.defined.add(m.group(1))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
in_vars_block = False
|
||||||
|
|
||||||
|
# --- loop_var
|
||||||
|
m_loop = self.ansible_loop_var.match(stripped)
|
||||||
|
if m_loop:
|
||||||
|
self.defined.add(m_loop.group(1))
|
||||||
|
|
||||||
|
# --- register: name
|
||||||
|
m_reg = re.match(r'^\s*register\s*:\s*([a-zA-Z_]\w*)', stripped)
|
||||||
|
if m_reg:
|
||||||
|
self.defined.add(m_reg.group(1))
|
||||||
|
|
||||||
|
# --- {% set var = ... %}
|
||||||
|
for m in self.jinja_set_def.finditer(line):
|
||||||
|
self.defined.add(m.group(1))
|
||||||
|
|
||||||
|
# --- {% for x [ , y ] in ... %}
|
||||||
|
for m in self.jinja_for_def.finditer(line):
|
||||||
|
self.defined.add(m.group(1))
|
||||||
|
if m.group(2):
|
||||||
|
self.defined.add(m.group(2))
|
||||||
|
|
||||||
|
# --- {% macro name(params...) %} -> collect parameter names
|
||||||
|
for m in self.jinja_macro_def.finditer(line):
|
||||||
|
params_blob = m.group(1)
|
||||||
|
# Split by comma at top level (macros don't support nested tuples in params)
|
||||||
|
params = [p.strip() for p in params_blob.split(',')]
|
||||||
|
for p in params:
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
# Strip * / ** for varargs/kwargs
|
||||||
|
p = p.lstrip('*')
|
||||||
|
# Drop default value part: name=...
|
||||||
|
name = p.split('=', 1)[0].strip()
|
||||||
|
if re.match(r'^[a-zA-Z_]\w*$', name):
|
||||||
|
self.defined.add(name)
|
||||||
|
except Exception:
|
||||||
|
# Ignore unreadable files
|
||||||
|
pass
|
||||||
|
|
||||||
def test_all_used_vars_are_defined(self):
|
def test_all_used_vars_are_defined(self):
|
||||||
|
"""
|
||||||
|
Scan all template/YAML files for {{ var }} usage and fail if a variable
|
||||||
|
is not known as defined and has no fallback keys (default_<var>/defaults_<var>).
|
||||||
|
"""
|
||||||
undefined_uses = []
|
undefined_uses = []
|
||||||
# Phase 2: scan all files for usages
|
|
||||||
for root, _, files in os.walk(self.project_root):
|
for root, _, files in os.walk(self.project_root):
|
||||||
for fn in files:
|
for fn in files:
|
||||||
ext = os.path.splitext(fn)[1]
|
ext = os.path.splitext(fn)[1]
|
||||||
if ext not in self.scan_extensions:
|
if ext not in self.scan_extensions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
path = os.path.join(root, fn)
|
path = os.path.join(root, fn)
|
||||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
try:
|
||||||
for lineno, line in enumerate(f, 1):
|
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
for m in self.simple_var_pattern.finditer(line):
|
for lineno, line in enumerate(f, 1):
|
||||||
var = m.group(1)
|
for m in self.simple_var_pattern.finditer(line):
|
||||||
# skip builtins and whitelisted names
|
var = m.group(1)
|
||||||
if var in ('lookup', 'role_name', 'domains', 'item', 'host_type',
|
|
||||||
'inventory_hostname', 'role_path', 'playbook_dir',
|
# Skip well-known Jinja/Ansible builtins and frequent loop aliases
|
||||||
'ansible_become_password', 'inventory_dir', 'ansible_memtotal_mb'):
|
if var in (
|
||||||
continue
|
'lookup', 'role_name', 'domains', 'item', 'host_type',
|
||||||
# skip defaults_var fallback
|
'inventory_hostname', 'role_path', 'playbook_dir',
|
||||||
if var not in self.defined and \
|
'ansible_become_password', 'inventory_dir', 'ansible_memtotal_mb'
|
||||||
f"default_{var}" not in self.defined and \
|
):
|
||||||
f"defaults_{var}" not in self.defined:
|
continue
|
||||||
undefined_uses.append(
|
|
||||||
f"{path}:{lineno}: '{{{{ {var} }}}}' used but not defined"
|
# Accept if defined directly or via fallback defaults
|
||||||
)
|
if (
|
||||||
|
var not in self.defined
|
||||||
|
and f"default_{var}" not in self.defined
|
||||||
|
and f"defaults_{var}" not in self.defined
|
||||||
|
):
|
||||||
|
undefined_uses.append(
|
||||||
|
f"{path}:{lineno}: '{{{{ {var} }}}}' used but not defined"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Ignore unreadable files
|
||||||
|
pass
|
||||||
|
|
||||||
if undefined_uses:
|
if undefined_uses:
|
||||||
self.fail(
|
self.fail(
|
||||||
"Undefined Jinja2 variables found (no fallback 'default_' or 'defaults_' key):\n" +
|
"Undefined Jinja2 variables found (no fallback 'default_' or 'defaults_' key):\n"
|
||||||
"\n".join(undefined_uses)
|
+ "\n".join(undefined_uses)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
0
tests/unit/roles/srv-web-inj-compose/__init__.py
Normal file
0
tests/unit/roles/srv-web-inj-compose/__init__.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py
|
||||||
|
import importlib.util
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
THIS_FILE = Path(__file__)
|
||||||
|
|
||||||
|
def find_repo_root(start: Path) -> Path:
|
||||||
|
target_rel = Path("roles") / "srv-web-7-7-inj-compose" / "filter_plugins" / "inj_enabled.py"
|
||||||
|
cur = start
|
||||||
|
for _ in range(12):
|
||||||
|
if (cur / target_rel).is_file():
|
||||||
|
return cur
|
||||||
|
cur = cur.parent
|
||||||
|
return start.parents[6]
|
||||||
|
|
||||||
|
REPO_ROOT = find_repo_root(THIS_FILE)
|
||||||
|
PLUGIN_PATH = REPO_ROOT / "roles" / "srv-web-7-7-inj-compose" / "filter_plugins" / "inj_enabled.py"
|
||||||
|
|
||||||
|
# Ensure 'module_utils' is importable under its canonical package name
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
# Import the same module path the plugin uses
|
||||||
|
mu_mod = import_module("module_utils.config_utils")
|
||||||
|
AppConfigKeyError = mu_mod.AppConfigKeyError
|
||||||
|
|
||||||
|
# Load inj_enabled filter plugin from file
|
||||||
|
spec = importlib.util.spec_from_file_location("inj_enabled", str(PLUGIN_PATH))
|
||||||
|
inj_mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(inj_mod) # type: ignore
|
||||||
|
FilterModule = inj_mod.FilterModule
|
||||||
|
|
||||||
|
|
||||||
|
def _get_filter():
|
||||||
|
fm = FilterModule()
|
||||||
|
flt = fm.filters().get("inj_enabled")
|
||||||
|
assert callable(flt), "inj_enabled filter not found or not callable"
|
||||||
|
return flt
|
||||||
|
|
||||||
|
|
||||||
|
class TestInjEnabledFilter(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.filter = _get_filter()
|
||||||
|
|
||||||
|
def test_basic_build(self):
|
||||||
|
applications = {
|
||||||
|
"myapp": {"features": {
|
||||||
|
"javascript": True, "logout": False, "css": True, "matomo": False, "desktop": True
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
features = ["javascript", "logout", "css", "matomo", "desktop"]
|
||||||
|
result = self.filter(applications, "myapp", features)
|
||||||
|
self.assertEqual(result, {
|
||||||
|
"javascript": True, "logout": False, "css": True, "matomo": False, "desktop": True
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_missing_keys_return_default_false(self):
|
||||||
|
applications = {"app": {"features": {"javascript": True}}}
|
||||||
|
result = self.filter(applications, "app", ["javascript", "logout", "css"], default=False)
|
||||||
|
self.assertEqual(result["javascript"], True)
|
||||||
|
self.assertEqual(result["logout"], False)
|
||||||
|
self.assertEqual(result["css"], False)
|
||||||
|
|
||||||
|
def test_default_true_applied_to_missing(self):
|
||||||
|
applications = {"app": {"features": {}}}
|
||||||
|
result = self.filter(applications, "app", ["logout", "css"], default=True)
|
||||||
|
self.assertEqual(result, {"logout": True, "css": True})
|
||||||
|
|
||||||
|
def test_custom_prefix(self):
|
||||||
|
applications = {"app": {"flags": {"logout": True, "css": False}}}
|
||||||
|
result = self.filter(applications, "app", ["logout", "css"], prefix="flags", default=False)
|
||||||
|
self.assertEqual(result, {"logout": True, "css": False})
|
||||||
|
|
||||||
|
def test_missing_application_id_raises(self):
|
||||||
|
applications = {"other": {"features": {"logout": True}}}
|
||||||
|
with self.assertRaises(AppConfigKeyError):
|
||||||
|
_ = self.filter(applications, "unknown-app", ["logout"])
|
||||||
|
|
||||||
|
def test_truthy_string_is_returned_as_is(self):
|
||||||
|
applications = {"app": {"features": {"logout": "true"}}}
|
||||||
|
result = self.filter(applications, "app", ["logout"], default=False)
|
||||||
|
self.assertEqual(result["logout"], "true")
|
||||||
|
|
||||||
|
def test_nonexistent_feature_path_uses_default(self):
|
||||||
|
applications = {"app": {"features": {}}}
|
||||||
|
result = self.filter(applications, "app", ["nonexistent"], default=False)
|
||||||
|
self.assertEqual(result["nonexistent"], False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user