mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-09-08 19:27:18 +02:00
Compare commits
12 Commits
d0cec9a7d4
...
9f734dff17
Author | SHA1 | Date | |
---|---|---|---|
9f734dff17 | |||
6fa4d00547 | |||
7254667186 | |||
aaedaab3da | |||
7791bd8c04 | |||
34b3f3b0ad | |||
94fe58b5da | |||
9feb766e6f | |||
231fd567b3 | |||
3f8e7c1733 | |||
3bfab9ef8e | |||
f1870c07be |
@@ -228,7 +228,7 @@ def parse_meta_dependencies(role_dir: str) -> List[str]:
|
|||||||
def sanitize_run_once_var(role_name: str) -> str:
|
def sanitize_run_once_var(role_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Generate run_once variable name from role name.
|
Generate run_once variable name from role name.
|
||||||
Example: 'sys-srv-web-inj-logout' -> 'run_once_sys_srv_web_inj_logout'
|
Example: 'sys-front-inj-logout' -> 'run_once_sys_front_inj_logout'
|
||||||
"""
|
"""
|
||||||
return "run_once_" + role_name.replace("-", "_")
|
return "run_once_" + role_name.replace("-", "_")
|
||||||
|
|
||||||
|
@@ -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,12 +1,15 @@
|
|||||||
from ansible.errors import AnsibleFilterError
|
from ansible.errors import AnsibleFilterError
|
||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
import sys, os
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure module_utils is importable when this filter runs from Ansible
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
from module_utils.config_utils import get_app_conf
|
from module_utils.config_utils import get_app_conf
|
||||||
from module_utils.get_url import get_url
|
from module_utils.get_url import get_url
|
||||||
|
|
||||||
|
|
||||||
class FilterModule(object):
|
class FilterModule(object):
|
||||||
"""
|
"""
|
||||||
Custom filters for Content Security Policy generation and CSP-related utilities.
|
Custom filters for Content Security Policy generation and CSP-related utilities.
|
||||||
@@ -17,10 +20,14 @@ class FilterModule(object):
|
|||||||
'build_csp_header': self.build_csp_header,
|
'build_csp_header': self.build_csp_header,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -------------------------------
|
||||||
|
# Helpers
|
||||||
|
# -------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_feature_enabled(applications: dict, feature: str, application_id: str) -> bool:
|
def is_feature_enabled(applications: dict, feature: str, application_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if applications[application_id].features[feature] is truthy.
|
Returns True if applications[application_id].features[feature] is truthy.
|
||||||
"""
|
"""
|
||||||
return get_app_conf(
|
return get_app_conf(
|
||||||
applications,
|
applications,
|
||||||
@@ -32,6 +39,10 @@ class FilterModule(object):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_csp_whitelist(applications, application_id, directive):
|
def get_csp_whitelist(applications, application_id, directive):
|
||||||
|
"""
|
||||||
|
Returns a list of additional whitelist entries for a given directive.
|
||||||
|
Accepts both scalar and list in config; always returns a list.
|
||||||
|
"""
|
||||||
wl = get_app_conf(
|
wl = get_app_conf(
|
||||||
applications,
|
applications,
|
||||||
application_id,
|
application_id,
|
||||||
@@ -48,28 +59,37 @@ class FilterModule(object):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_csp_flags(applications, application_id, directive):
|
def get_csp_flags(applications, application_id, directive):
|
||||||
"""
|
"""
|
||||||
Dynamically extract all CSP flags for a given directive and return them as tokens,
|
Returns CSP flag tokens (e.g., "'unsafe-eval'", "'unsafe-inline'") for a directive,
|
||||||
e.g., "'unsafe-eval'", "'unsafe-inline'", etc.
|
merging sane defaults with app config.
|
||||||
|
Default: 'unsafe-inline' is enabled for style-src and style-src-elem.
|
||||||
"""
|
"""
|
||||||
flags = get_app_conf(
|
# Defaults that apply to all apps
|
||||||
|
default_flags = {}
|
||||||
|
if directive in ('style-src', 'style-src-elem'):
|
||||||
|
default_flags = {'unsafe-inline': True}
|
||||||
|
|
||||||
|
configured = get_app_conf(
|
||||||
applications,
|
applications,
|
||||||
application_id,
|
application_id,
|
||||||
'server.csp.flags.' + directive,
|
'server.csp.flags.' + directive,
|
||||||
False,
|
False,
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
tokens = []
|
|
||||||
|
|
||||||
for flag_name, enabled in flags.items():
|
# Merge defaults with configured flags (configured overrides defaults)
|
||||||
|
merged = {**default_flags, **configured}
|
||||||
|
|
||||||
|
tokens = []
|
||||||
|
for flag_name, enabled in merged.items():
|
||||||
if enabled:
|
if enabled:
|
||||||
tokens.append(f"'{flag_name}'")
|
tokens.append(f"'{flag_name}'")
|
||||||
|
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_csp_inline_content(applications, application_id, directive):
|
def get_csp_inline_content(applications, application_id, directive):
|
||||||
"""
|
"""
|
||||||
Return inline script/style snippets to hash for a given CSP directive.
|
Returns inline script/style snippets to hash for a given directive.
|
||||||
|
Accepts both scalar and list in config; always returns a list.
|
||||||
"""
|
"""
|
||||||
snippets = get_app_conf(
|
snippets = get_app_conf(
|
||||||
applications,
|
applications,
|
||||||
@@ -87,7 +107,7 @@ class FilterModule(object):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_csp_hash(content):
|
def get_csp_hash(content):
|
||||||
"""
|
"""
|
||||||
Compute the SHA256 hash of the given inline content and return
|
Computes the SHA256 hash of the given inline content and returns
|
||||||
a CSP token like "'sha256-<base64>'".
|
a CSP token like "'sha256-<base64>'".
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -97,6 +117,10 @@ class FilterModule(object):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise AnsibleFilterError(f"get_csp_hash failed: {exc}")
|
raise AnsibleFilterError(f"get_csp_hash failed: {exc}")
|
||||||
|
|
||||||
|
# -------------------------------
|
||||||
|
# Main builder
|
||||||
|
# -------------------------------
|
||||||
|
|
||||||
def build_csp_header(
|
def build_csp_header(
|
||||||
self,
|
self,
|
||||||
applications,
|
applications,
|
||||||
@@ -106,75 +130,80 @@ class FilterModule(object):
|
|||||||
matomo_feature_name='matomo'
|
matomo_feature_name='matomo'
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Build the Content-Security-Policy header value dynamically based on application settings.
|
Builds the Content-Security-Policy header value dynamically based on application settings.
|
||||||
Inline hashes are read from applications[application_id].csp.hashes
|
- Flags (e.g., 'unsafe-eval', 'unsafe-inline') are read from server.csp.flags.<directive>,
|
||||||
|
with sane defaults applied in get_csp_flags (always 'unsafe-inline' for style-src and style-src-elem).
|
||||||
|
- Inline hashes are read from server.csp.hashes.<directive>.
|
||||||
|
- Whitelists are read from server.csp.whitelist.<directive>.
|
||||||
|
- Inline hashes are added only if the final tokens do NOT include 'unsafe-inline'.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
directives = [
|
directives = [
|
||||||
'default-src', # Fallback source list for all content types not explicitly listed
|
'default-src', # Fallback source list for content types not explicitly listed
|
||||||
'connect-src', # Controls allowed URLs for XHR, WebSockets, EventSource, and fetch()
|
'connect-src', # Allowed URLs for XHR, WebSockets, EventSource, fetch()
|
||||||
'frame-ancestors', # Restricts which parent frames can embed this page via <iframe>, <object>, <embed>, <applet>
|
'frame-ancestors', # Who may embed this page
|
||||||
'frame-src', # Controls allowed sources for nested browsing contexts like <iframe>
|
'frame-src', # Sources for nested browsing contexts (e.g., <iframe>)
|
||||||
'script-src', # Controls allowed sources for inline scripts and <script> elements (general script execution)
|
'script-src', # Sources for script execution
|
||||||
'script-src-elem', # Controls allowed sources specifically for <script> elements (separate from inline/event handlers)
|
'script-src-elem', # Sources for <script> elements
|
||||||
'style-src', # Controls allowed sources for inline styles and <style>/<link> elements (general styles)
|
'style-src', # Sources for inline styles and <style>/<link> elements
|
||||||
'style-src-elem', # Controls allowed sources specifically for <style> and <link rel="stylesheet"> elements
|
'style-src-elem', # Sources for <style> and <link rel="stylesheet">
|
||||||
'font-src', # Controls allowed sources for fonts loaded via @font-face
|
'font-src', # Sources for fonts
|
||||||
'worker-src', # Controls allowed sources for web workers, shared workers, and service workers
|
'worker-src', # Sources for workers
|
||||||
'manifest-src', # Controls allowed sources for web app manifests
|
'manifest-src', # Sources for web app manifests
|
||||||
'media-src', # Controls allowed sources for media files like <audio> and <video>
|
'media-src', # Sources for audio and video
|
||||||
]
|
]
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
|
||||||
for directive in directives:
|
for directive in directives:
|
||||||
tokens = ["'self'"]
|
tokens = ["'self'"]
|
||||||
|
|
||||||
# unsafe-eval / unsafe-inline flags
|
# 1) Load flags (includes defaults from get_csp_flags)
|
||||||
flags = self.get_csp_flags(applications, application_id, directive)
|
flags = self.get_csp_flags(applications, application_id, directive)
|
||||||
tokens += flags
|
tokens += flags
|
||||||
|
|
||||||
if directive in [ 'script-src-elem', 'connect-src', 'style-src-elem' ]:
|
# 2) Allow fetching from internal CDN by default for selected directives
|
||||||
# Allow fetching from internal CDN as default for all applications
|
if directive in ['script-src-elem', 'connect-src', 'style-src-elem']:
|
||||||
tokens.append(get_url(domains,'web-svc-cdn',web_protocol))
|
tokens.append(get_url(domains, 'web-svc-cdn', web_protocol))
|
||||||
|
|
||||||
if directive in ['script-src-elem', 'connect-src']:
|
|
||||||
# Matomo integration
|
|
||||||
if self.is_feature_enabled(applications, matomo_feature_name, application_id):
|
|
||||||
tokens.append(get_url(domains,'web-app-matomo',web_protocol))
|
|
||||||
|
|
||||||
# ReCaptcha integration: allow loading scripts from Google if feature enabled
|
# 3) Matomo integration if feature is enabled
|
||||||
|
if directive in ['script-src-elem', 'connect-src']:
|
||||||
|
if self.is_feature_enabled(applications, matomo_feature_name, application_id):
|
||||||
|
tokens.append(get_url(domains, 'web-app-matomo', web_protocol))
|
||||||
|
|
||||||
|
# 4) ReCaptcha integration (scripts + frames) if feature is enabled
|
||||||
if self.is_feature_enabled(applications, 'recaptcha', application_id):
|
if self.is_feature_enabled(applications, 'recaptcha', application_id):
|
||||||
if directive in ['script-src-elem',"frame-src"]:
|
if directive in ['script-src-elem', 'frame-src']:
|
||||||
tokens.append('https://www.gstatic.com')
|
tokens.append('https://www.gstatic.com')
|
||||||
tokens.append('https://www.google.com')
|
tokens.append('https://www.google.com')
|
||||||
|
|
||||||
|
# 5) Frame ancestors handling (desktop + logout support)
|
||||||
if directive == 'frame-ancestors':
|
if directive == 'frame-ancestors':
|
||||||
# Enable loading via ancestors
|
|
||||||
if self.is_feature_enabled(applications, 'desktop', application_id):
|
if self.is_feature_enabled(applications, 'desktop', application_id):
|
||||||
|
# Allow being embedded by the desktop app domain (and potentially its parent)
|
||||||
domain = domains.get('web-app-desktop')[0]
|
domain = domains.get('web-app-desktop')[0]
|
||||||
sld_tld = ".".join(domain.split(".")[-2:]) # yields "example.com"
|
sld_tld = ".".join(domain.split(".")[-2:]) # e.g., example.com
|
||||||
tokens.append(f"{sld_tld}") # yields "*.example.com"
|
tokens.append(f"{sld_tld}")
|
||||||
|
|
||||||
if self.is_feature_enabled(applications, 'logout', application_id):
|
if self.is_feature_enabled(applications, 'logout', application_id):
|
||||||
|
# Allow embedding via logout proxy and Keycloak app
|
||||||
# Allow logout via infinito logout proxy
|
tokens.append(get_url(domains, 'web-svc-logout', web_protocol))
|
||||||
tokens.append(get_url(domains,'web-svc-logout',web_protocol))
|
tokens.append(get_url(domains, 'web-app-keycloak', web_protocol))
|
||||||
|
|
||||||
# Allow logout via keycloak app
|
|
||||||
tokens.append(get_url(domains,'web-app-keycloak',web_protocol))
|
|
||||||
|
|
||||||
# whitelist
|
# 6) Custom whitelist entries
|
||||||
tokens += self.get_csp_whitelist(applications, application_id, directive)
|
tokens += self.get_csp_whitelist(applications, application_id, directive)
|
||||||
|
|
||||||
# only add hashes if 'unsafe-inline' is NOT in flags
|
# 7) Add inline content hashes ONLY if final tokens do NOT include 'unsafe-inline'
|
||||||
if "'unsafe-inline'" not in flags:
|
# (Check tokens, not flags, to include defaults and later modifications.)
|
||||||
|
if "'unsafe-inline'" not in tokens:
|
||||||
for snippet in self.get_csp_inline_content(applications, application_id, directive):
|
for snippet in self.get_csp_inline_content(applications, application_id, directive):
|
||||||
tokens.append(self.get_csp_hash(snippet))
|
tokens.append(self.get_csp_hash(snippet))
|
||||||
|
|
||||||
|
# Append directive
|
||||||
parts.append(f"{directive} {' '.join(tokens)};")
|
parts.append(f"{directive} {' '.join(tokens)};")
|
||||||
|
|
||||||
# static img-src
|
# 8) Static img-src directive (kept permissive for data/blob and any host)
|
||||||
parts.append("img-src * data: blob:;")
|
parts.append("img-src * data: blob:;")
|
||||||
|
|
||||||
return ' '.join(parts)
|
return ' '.join(parts)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
@@ -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,28 +0,0 @@
|
|||||||
"""
|
|
||||||
Ansible filter plugin that joins a base string and a tail path safely.
|
|
||||||
If the base is falsy (None, empty, etc.), returns an empty string.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def safe_join(base, tail):
|
|
||||||
"""
|
|
||||||
Safely join base and tail into a path or URL.
|
|
||||||
|
|
||||||
- base: the base string. If falsy, returns ''.
|
|
||||||
- tail: the string to append. Leading/trailing slashes are handled.
|
|
||||||
- On any exception, returns ''.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not base:
|
|
||||||
return ''
|
|
||||||
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,
|
|
||||||
}
|
|
146
filter_plugins/url_join.py
Normal file
146
filter_plugins/url_join.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Ansible filter plugin that safely joins URL components from a list.
|
||||||
|
- Requires a valid '<scheme>://' in the first element (any RFC-3986-ish scheme)
|
||||||
|
- Preserves the double slash after the scheme, collapses other duplicate slashes
|
||||||
|
- Supports query parts introduced by elements starting with '?' or '&'
|
||||||
|
* first query element uses '?', subsequent use '&' (regardless of given prefix)
|
||||||
|
* each query element must be exactly one 'key=value' pair
|
||||||
|
* query elements may only appear after path elements; once query starts, no more path parts
|
||||||
|
- Raises specific AnsibleFilterError messages for common misuse
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
_SCHEME_RE = re.compile(r'^([a-zA-Z][a-zA-Z0-9+.\-]*://)(.*)$')
|
||||||
|
_QUERY_PAIR_RE = re.compile(r'^[^&=?#]+=[^&?#]*$') # key=value (no '&', no extra '?' or '#')
|
||||||
|
|
||||||
|
def _to_str_or_error(obj, index):
|
||||||
|
"""Cast to str, raising a specific AnsibleFilterError with index context."""
|
||||||
|
try:
|
||||||
|
return str(obj)
|
||||||
|
except Exception as e:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: unable to convert part at index {index} to string: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def url_join(parts):
|
||||||
|
"""
|
||||||
|
Join a list of URL parts, URL-aware (scheme, path, query).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parts (list|tuple): URL segments. First element MUST include '<scheme>://'.
|
||||||
|
Path elements are plain strings.
|
||||||
|
Query elements must start with '?' or '&' and contain exactly one 'key=value'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Joined URL.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AnsibleFilterError: with specific, descriptive messages.
|
||||||
|
"""
|
||||||
|
# --- basic input validation ---
|
||||||
|
if parts is None:
|
||||||
|
raise AnsibleFilterError("url_join: parts must be a non-empty list; got None")
|
||||||
|
if not isinstance(parts, (list, tuple)):
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: parts must be a list/tuple; got {type(parts).__name__}"
|
||||||
|
)
|
||||||
|
if len(parts) == 0:
|
||||||
|
raise AnsibleFilterError("url_join: parts must be a non-empty list")
|
||||||
|
|
||||||
|
# --- first element must carry a scheme ---
|
||||||
|
first_raw = parts[0]
|
||||||
|
if first_raw is None:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
"url_join: first element must include a scheme like 'https://'; got None"
|
||||||
|
)
|
||||||
|
|
||||||
|
first_str = _to_str_or_error(first_raw, 0)
|
||||||
|
m = _SCHEME_RE.match(first_str)
|
||||||
|
if not m:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
"url_join: first element must start with '<scheme>://', e.g. 'https://example.com'; "
|
||||||
|
f"got '{first_str}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
scheme = m.group(1) # e.g., 'https://', 'ftp://', 'myapp+v1://'
|
||||||
|
after_scheme = m.group(2).lstrip('/') # strip only leading slashes right after scheme
|
||||||
|
|
||||||
|
# --- iterate parts: collect path parts until first query part; then only query parts allowed ---
|
||||||
|
path_parts = []
|
||||||
|
query_pairs = []
|
||||||
|
in_query = False
|
||||||
|
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p is None:
|
||||||
|
# skip None silently (consistent with path_join-ish behavior)
|
||||||
|
continue
|
||||||
|
|
||||||
|
s = _to_str_or_error(p, i)
|
||||||
|
|
||||||
|
# disallow additional scheme in later parts
|
||||||
|
if i > 0 and "://" in s:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: only the first element may contain a scheme; part at index {i} "
|
||||||
|
f"looks like a URL with scheme ('{s}')."
|
||||||
|
)
|
||||||
|
|
||||||
|
# first element: replace with remainder after scheme and continue
|
||||||
|
if i == 0:
|
||||||
|
s = after_scheme
|
||||||
|
|
||||||
|
# check if this is a query element (starts with ? or &)
|
||||||
|
if s.startswith('?') or s.startswith('&'):
|
||||||
|
in_query = True
|
||||||
|
raw_pair = s[1:] # strip the leading ? or &
|
||||||
|
if raw_pair == '':
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: query element at index {i} is empty; expected '?key=value' or '&key=value'"
|
||||||
|
)
|
||||||
|
# Disallow multiple pairs in a single element; enforce exactly one key=value
|
||||||
|
if '&' in raw_pair:
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: query element at index {i} must contain exactly one 'key=value' pair "
|
||||||
|
f"without '&'; got '{s}'"
|
||||||
|
)
|
||||||
|
if not _QUERY_PAIR_RE.match(raw_pair):
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: query element at index {i} must match 'key=value' (no extra '?', '&', '#'); got '{s}'"
|
||||||
|
)
|
||||||
|
query_pairs.append(raw_pair)
|
||||||
|
else:
|
||||||
|
# non-query element
|
||||||
|
if in_query:
|
||||||
|
# once query started, no more path parts allowed
|
||||||
|
raise AnsibleFilterError(
|
||||||
|
f"url_join: path element found at index {i} after query parameters started; "
|
||||||
|
f"query parts must come last"
|
||||||
|
)
|
||||||
|
# normal path part: strip slashes to avoid duplicate '/'
|
||||||
|
path_parts.append(s.strip('/'))
|
||||||
|
|
||||||
|
# normalize path: remove empty chunks
|
||||||
|
path_parts = [p for p in path_parts if p != '']
|
||||||
|
|
||||||
|
# --- build result ---
|
||||||
|
# path portion
|
||||||
|
if path_parts:
|
||||||
|
joined_path = "/".join(path_parts)
|
||||||
|
base = scheme + joined_path
|
||||||
|
else:
|
||||||
|
# no path beyond scheme
|
||||||
|
base = scheme
|
||||||
|
|
||||||
|
# query portion
|
||||||
|
if query_pairs:
|
||||||
|
base = base + "?" + "&".join(query_pairs)
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
'url_join': url_join,
|
||||||
|
}
|
53
lookup_plugins/local_mtime_qs.py
Normal file
53
lookup_plugins/local_mtime_qs.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from ansible.plugins.lookup import LookupBase
|
||||||
|
from ansible.errors import AnsibleError
|
||||||
|
import os
|
||||||
|
|
||||||
|
class LookupModule(LookupBase):
|
||||||
|
"""
|
||||||
|
Return a cache-busting string based on the LOCAL file's mtime.
|
||||||
|
|
||||||
|
Usage (single path → string via Jinja):
|
||||||
|
{{ lookup('local_mtime_qs', '/path/to/file.css') }}
|
||||||
|
-> "?version=1712323456"
|
||||||
|
|
||||||
|
Options:
|
||||||
|
param (str): query parameter name (default: "version")
|
||||||
|
mode (str): "qs" (default) → returns "?<param>=<mtime>"
|
||||||
|
"epoch" → returns "<mtime>"
|
||||||
|
|
||||||
|
Multiple paths (returns list, one result per term):
|
||||||
|
{{ lookup('local_mtime_qs', '/a.js', '/b.js', param='v') }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run(self, terms, variables=None, **kwargs):
|
||||||
|
if not terms:
|
||||||
|
return []
|
||||||
|
|
||||||
|
param = kwargs.get('param', 'version')
|
||||||
|
mode = kwargs.get('mode', 'qs')
|
||||||
|
|
||||||
|
if mode not in ('qs', 'epoch'):
|
||||||
|
raise AnsibleError("local_mtime_qs: 'mode' must be 'qs' or 'epoch'")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for term in terms:
|
||||||
|
path = os.path.abspath(os.path.expanduser(str(term)))
|
||||||
|
|
||||||
|
# Fail fast if path is missing or not a regular file
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise AnsibleError(f"local_mtime_qs: file does not exist: {path}")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise AnsibleError(f"local_mtime_qs: not a regular file: {path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
mtime = int(os.stat(path).st_mtime)
|
||||||
|
except OSError as e:
|
||||||
|
raise AnsibleError(f"local_mtime_qs: cannot stat '{path}': {e}")
|
||||||
|
|
||||||
|
if mode == 'qs':
|
||||||
|
results.append(f"?{param}={mtime}")
|
||||||
|
else: # mode == 'epoch'
|
||||||
|
results.append(str(mtime))
|
||||||
|
|
||||||
|
return results
|
@@ -56,6 +56,16 @@ roles:
|
|||||||
description: "Stack levels to setup the server"
|
description: "Stack levels to setup the server"
|
||||||
icon: "fas fa-bars-staggered"
|
icon: "fas fa-bars-staggered"
|
||||||
invokable: false
|
invokable: false
|
||||||
|
front:
|
||||||
|
title: "System Frontend Helpers"
|
||||||
|
description: "Frontend helpers for reverse-proxied apps (injection, shared assets, CDN plumbing)."
|
||||||
|
icon: "fas fa-wand-magic-sparkles"
|
||||||
|
invokable: false
|
||||||
|
inj:
|
||||||
|
title: "Injection"
|
||||||
|
description: "Composable HTML injection roles (CSS, JS, logout interceptor, analytics, desktop iframe) for Nginx/OpenResty via sub_filter/Lua with CDN-backed assets."
|
||||||
|
icon: "fas fa-filter"
|
||||||
|
invokable: false
|
||||||
update:
|
update:
|
||||||
title: "Updates & Package Management"
|
title: "Updates & Package Management"
|
||||||
description: "OS & package updates"
|
description: "OS & package updates"
|
||||||
@@ -106,11 +116,6 @@ roles:
|
|||||||
description: "General server roles for provisioning and managing server infrastructure—covering web servers, proxy servers, network services, and other backend components."
|
description: "General server roles for provisioning and managing server infrastructure—covering web servers, proxy servers, network services, and other backend components."
|
||||||
icon: "fas fa-server"
|
icon: "fas fa-server"
|
||||||
invokable: false
|
invokable: false
|
||||||
web:
|
|
||||||
title: "Webserver"
|
|
||||||
description: "Web-server roles for installing and configuring Nginx (core, TLS, injection filters, composer modules)."
|
|
||||||
icon: "fas fa-server"
|
|
||||||
invokable: false
|
|
||||||
proxy:
|
proxy:
|
||||||
title: "Proxy Server"
|
title: "Proxy Server"
|
||||||
description: "Proxy-server roles for virtual-host orchestration and reverse-proxy setups."
|
description: "Proxy-server roles for virtual-host orchestration and reverse-proxy setups."
|
||||||
|
@@ -19,3 +19,5 @@
|
|||||||
template:
|
template:
|
||||||
src: caffeine.desktop.j2
|
src: caffeine.desktop.j2
|
||||||
dest: "{{auto_start_directory}}caffeine.desktop"
|
dest: "{{auto_start_directory}}caffeine.desktop"
|
||||||
|
|
||||||
|
- include_tasks: utils/run_once.yml
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
- block:
|
- block:
|
||||||
- include_tasks: 01_core.yml
|
- include_tasks: 01_core.yml
|
||||||
- include_tasks: utils/run_once.yml
|
|
||||||
when: run_once_desk_gnome_caffeine is not defined
|
when: run_once_desk_gnome_caffeine is not defined
|
||||||
|
@@ -48,4 +48,6 @@
|
|||||||
state: present
|
state: present
|
||||||
create: yes
|
create: yes
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
become: false
|
become: false
|
||||||
|
|
||||||
|
- include_tasks: utils/run_once.yml
|
@@ -1,4 +1,3 @@
|
|||||||
- block:
|
- block:
|
||||||
- include_tasks: 01_core.yml
|
- include_tasks: 01_core.yml
|
||||||
- include_tasks: utils/run_once.yml
|
|
||||||
when: run_once_desk_ssh is not defined
|
when: run_once_desk_ssh is not defined
|
@@ -1,8 +1,14 @@
|
|||||||
---
|
---
|
||||||
- name: Setup locale.gen
|
- name: Setup locale.gen
|
||||||
template: src=locale.gen dest=/etc/locale.gen
|
template:
|
||||||
|
src: locale.gen.j2
|
||||||
|
dest: /etc/locale.gen
|
||||||
|
|
||||||
- name: Setup locale.conf
|
- name: Setup locale.conf
|
||||||
template: src=locale.conf dest=/etc/locale.conf
|
template:
|
||||||
|
src: locale.conf.j2
|
||||||
|
dest: /etc/locale.conf
|
||||||
|
|
||||||
- name: Generate locales
|
- name: Generate locales
|
||||||
shell: locale-gen
|
shell: locale-gen
|
||||||
become: true
|
become: true
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
LANG=en_US.UTF-8
|
|
||||||
LANGUAGE=en_US.UTF-8
|
|
2
roles/dev-locales/templates/locale.conf.j2
Normal file
2
roles/dev-locales/templates/locale.conf.j2
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
LANG={{ HOST_LL_CC }}.UTF-8
|
||||||
|
LANGUAGE={{ HOST_LL_CC }}.UTF-8
|
@@ -3,6 +3,10 @@
|
|||||||
- "CMD"
|
- "CMD"
|
||||||
- "curl"
|
- "curl"
|
||||||
- "-f"
|
- "-f"
|
||||||
|
{% if container_hostname %}
|
||||||
|
- "-H"
|
||||||
|
- "Host: {{ container_hostname }}"
|
||||||
|
{% endif %}
|
||||||
- "http://127.0.0.1{{ (":" ~ container_port) if container_port is defined else '' }}/{{ container_healthcheck | default('') }}"
|
- "http://127.0.0.1{{ (":" ~ container_port) if container_port is defined else '' }}/{{ container_healthcheck | default('') }}"
|
||||||
interval: 1m
|
interval: 1m
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This Ansible role composes and orchestrates all necessary HTTPS-layer tasks and HTML-content injections for your webserver domains. It integrates two key sub-roles into a unified workflow:
|
This Ansible role composes and orchestrates all necessary HTTPS-layer tasks and HTML-content injections for your webserver domains. It integrates two key sub-roles into a unified workflow:
|
||||||
|
|
||||||
1. **`sys-srv-web-inj-compose`**
|
1. **`sys-front-inj-all`**
|
||||||
Injects global HTML snippets (CSS, Matomo tracking, iFrame notifier, custom JavaScript) into responses using Nginx `sub_filter`.
|
Injects global HTML snippets (CSS, Matomo tracking, iFrame notifier, custom JavaScript) into responses using Nginx `sub_filter`.
|
||||||
2. **`sys-svc-certs`**
|
2. **`sys-svc-certs`**
|
||||||
Handles issuing, renewing, and managing TLS certificates via ACME/Certbot.
|
Handles issuing, renewing, and managing TLS certificates via ACME/Certbot.
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
# run_once_srv_composer: deactivated
|
# run_once_srv_composer: deactivated
|
||||||
|
|
||||||
- name: "include role sys-srv-web-inj-compose for '{{ domain }}'"
|
- name: "include role sys-front-inj-all for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: sys-srv-web-inj-compose
|
name: sys-front-inj-all
|
||||||
|
|
||||||
- name: "include role sys-svc-certs for '{{ domain }}'"
|
- name: "include role sys-svc-certs for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
|
@@ -35,6 +35,6 @@ location {{location}}
|
|||||||
|
|
||||||
{% if proxy_lua_enabled %}
|
{% if proxy_lua_enabled %}
|
||||||
proxy_set_header Accept-Encoding "";
|
proxy_set_header Accept-Encoding "";
|
||||||
{% include 'roles/sys-srv-web-inj-compose/templates/location.lua.j2'%}
|
{% include 'roles/sys-front-inj-all/templates/location.lua.j2'%}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
}
|
}
|
@@ -7,7 +7,7 @@ server
|
|||||||
{% include 'roles/web-app-oauth2-proxy/templates/endpoint.conf.j2'%}
|
{% include 'roles/web-app-oauth2-proxy/templates/endpoint.conf.j2'%}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include 'roles/sys-srv-web-inj-compose/templates/server.conf.j2'%}
|
{% include 'roles/sys-front-inj-all/templates/server.conf.j2'%}
|
||||||
|
|
||||||
{% if proxy_extra_configuration is defined %}
|
{% if proxy_extra_configuration is defined %}
|
||||||
{# Additional Domain Specific Configuration #}
|
{# Additional Domain Specific Configuration #}
|
||||||
|
@@ -8,7 +8,7 @@ server {
|
|||||||
|
|
||||||
{% include 'roles/srv-letsencrypt/templates/ssl_header.j2' %}
|
{% include 'roles/srv-letsencrypt/templates/ssl_header.j2' %}
|
||||||
|
|
||||||
{% include 'roles/sys-srv-web-inj-compose/templates/server.conf.j2' %}
|
{% include 'roles/sys-front-inj-all/templates/server.conf.j2' %}
|
||||||
|
|
||||||
client_max_body_size {{ client_max_body_size | default('100m') }};
|
client_max_body_size {{ client_max_body_size | default('100m') }};
|
||||||
keepalive_timeout 70;
|
keepalive_timeout 70;
|
||||||
|
@@ -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,4 +1,3 @@
|
|||||||
# roles/sys-srv-web-inj-compose/filter_plugins/inj_enabled.py
|
|
||||||
#
|
#
|
||||||
# Usage in tasks:
|
# Usage in tasks:
|
||||||
# - set_fact:
|
# - set_fact:
|
@@ -2,10 +2,10 @@
|
|||||||
Jinja filter: `inj_features(kind)` filters a list of features to only those
|
Jinja filter: `inj_features(kind)` filters a list of features to only those
|
||||||
that actually provide the corresponding snippet template file.
|
that actually provide the corresponding snippet template file.
|
||||||
|
|
||||||
- kind='head' -> roles/sys-srv-web-inj-<feature>/templates/head_sub.j2
|
- kind='head' -> roles/sys-front-inj-<feature>/templates/head_sub.j2
|
||||||
- kind='body' -> roles/sys-srv-web-inj-<feature>/templates/body_sub.j2
|
- kind='body' -> roles/sys-front-inj-<feature>/templates/body_sub.j2
|
||||||
|
|
||||||
If the feature's role directory (roles/sys-srv-web-inj-<feature>) does not
|
If the feature's role directory (roles/sys-front-inj-<feature>) does not
|
||||||
exist, this filter raises FileNotFoundError.
|
exist, this filter raises FileNotFoundError.
|
||||||
|
|
||||||
Usage in a template:
|
Usage in a template:
|
||||||
@@ -15,13 +15,13 @@ Usage in a template:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# This file lives at: roles/sys-srv-web-inj-compose/filter_plugins/inj_snippets.py
|
# This file lives at: roles/sys-front-inj-all/filter_plugins/inj_snippets.py
|
||||||
_THIS_DIR = os.path.dirname(__file__)
|
_THIS_DIR = os.path.dirname(__file__)
|
||||||
_ROLE_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..")) # roles/sys-srv-web-inj-compose
|
_ROLE_DIR = os.path.abspath(os.path.join(_THIS_DIR, "..")) # roles/sys-front-inj-all
|
||||||
_ROLES_DIR = os.path.abspath(os.path.join(_ROLE_DIR, "..")) # roles
|
_ROLES_DIR = os.path.abspath(os.path.join(_ROLE_DIR, "..")) # roles
|
||||||
|
|
||||||
def _feature_role_dir(feature: str) -> str:
|
def _feature_role_dir(feature: str) -> str:
|
||||||
return os.path.join(_ROLES_DIR, f"sys-srv-web-inj-{feature}")
|
return os.path.join(_ROLES_DIR, f"sys-front-inj-{feature}")
|
||||||
|
|
||||||
def _has_snippet(feature: str, kind: str) -> bool:
|
def _has_snippet(feature: str, kind: str) -> bool:
|
||||||
if kind not in ("head", "body"):
|
if kind not in ("head", "body"):
|
@@ -14,7 +14,7 @@ galaxy_info:
|
|||||||
- theming
|
- theming
|
||||||
repository: "https://s.infinito.nexus/code"
|
repository: "https://s.infinito.nexus/code"
|
||||||
issue_tracker_url: "https://s.infinito.nexus/issues"
|
issue_tracker_url: "https://s.infinito.nexus/issues"
|
||||||
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-srv-web-inj-compose"
|
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-all"
|
||||||
min_ansible_version: "2.9"
|
min_ansible_version: "2.9"
|
||||||
platforms:
|
platforms:
|
||||||
- name: Any
|
- name: Any
|
@@ -2,39 +2,10 @@
|
|||||||
set_fact:
|
set_fact:
|
||||||
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
|
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
|
||||||
|
|
||||||
- block:
|
- name: "Load CDN Service for '{{ domain }}'"
|
||||||
- name: Include dependency 'srv-core'
|
|
||||||
include_role:
|
|
||||||
name: srv-core
|
|
||||||
when: run_once_srv_core is not defined
|
|
||||||
- include_tasks: utils/run_once.yml
|
|
||||||
when: run_once_sys_srv_web_inj_compose is not defined
|
|
||||||
|
|
||||||
- name: "Activate Portfolio iFrame notifier for '{{ domain }}'"
|
|
||||||
include_role:
|
include_role:
|
||||||
name: sys-srv-web-inj-desktop
|
name: sys-svc-cdn
|
||||||
public: true # Vars used in templates
|
public: true # Expose variables so that they can be used in all injection roles
|
||||||
when: inj_enabled.desktop
|
|
||||||
|
|
||||||
- name: "Load CDN for '{{ domain }}'"
|
|
||||||
include_role:
|
|
||||||
name: web-svc-cdn
|
|
||||||
public: false
|
|
||||||
when:
|
|
||||||
- inj_enabled.logout
|
|
||||||
- inj_enabled.desktop
|
|
||||||
- application_id != 'web-svc-cdn'
|
|
||||||
- run_once_web_svc_cdn is not defined
|
|
||||||
|
|
||||||
- name: Overwritte CDN handlers with neutral handlers
|
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/tasks/utils/load_handlers.yml"
|
|
||||||
loop:
|
|
||||||
- svc-prx-openresty
|
|
||||||
- docker-compose
|
|
||||||
loop_control:
|
|
||||||
label: "{{ item }}"
|
|
||||||
vars:
|
|
||||||
handler_role_name: "{{ item }}"
|
|
||||||
|
|
||||||
- name: Reinitialize 'inj_enabled' for '{{ domain }}', after modification by CDN
|
- name: Reinitialize 'inj_enabled' for '{{ domain }}', after modification by CDN
|
||||||
set_fact:
|
set_fact:
|
||||||
@@ -42,25 +13,37 @@
|
|||||||
inj_head_features: "{{ SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('head') }}"
|
inj_head_features: "{{ SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('head') }}"
|
||||||
inj_body_features: "{{ SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('body') }}"
|
inj_body_features: "{{ SRV_WEB_INJ_COMP_FEATURES_ALL | inj_features('body') }}"
|
||||||
|
|
||||||
|
- name: "Activate Desktop iFrame notifier for '{{ domain }}'"
|
||||||
|
include_role:
|
||||||
|
name: sys-front-inj-desktop
|
||||||
|
public: true # Vars used in templates
|
||||||
|
when: inj_enabled.desktop
|
||||||
|
|
||||||
- name: "Activate Corporate CSS for '{{ domain }}'"
|
- name: "Activate Corporate CSS for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: sys-srv-web-inj-css
|
name: sys-front-inj-css
|
||||||
when:
|
when: inj_enabled.css
|
||||||
- inj_enabled.css
|
|
||||||
- run_once_sys_srv_web_inj_css is not defined
|
|
||||||
|
|
||||||
- name: "Activate Matomo Tracking for '{{ domain }}'"
|
- name: "Activate Matomo Tracking for '{{ domain }}'"
|
||||||
include_role:
|
include_role:
|
||||||
name: sys-srv-web-inj-matomo
|
name: sys-front-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: sys-srv-web-inj-javascript
|
name: sys-front-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: sys-srv-web-inj-logout
|
name: sys-front-inj-logout
|
||||||
public: true # Vars used in templates
|
public: true # Vars used in templates
|
||||||
when: inj_enabled.logout
|
when: inj_enabled.logout
|
||||||
|
|
||||||
|
- block:
|
||||||
|
- name: Include dependency 'srv-core'
|
||||||
|
include_role:
|
||||||
|
name: srv-core
|
||||||
|
when: run_once_srv_core is not defined
|
||||||
|
- include_tasks: utils/run_once.yml
|
||||||
|
when: run_once_sys_front_inj_all is not defined
|
@@ -3,7 +3,7 @@
|
|||||||
{% set kind = list_name | regex_replace('_snippets$','') %}
|
{% set kind = list_name | regex_replace('_snippets$','') %}
|
||||||
{% for f in features if inj_enabled.get(f) -%}
|
{% for f in features if inj_enabled.get(f) -%}
|
||||||
{{ list_name }}[#{{ list_name }} + 1] = [=[
|
{{ list_name }}[#{{ list_name }} + 1] = [=[
|
||||||
{%- include 'roles/sys-srv-web-inj-' ~ f ~ '/templates/' ~ kind ~ '_sub.j2' -%}
|
{%- include 'roles/sys-front-inj-' ~ f ~ '/templates/' ~ kind ~ '_sub.j2' -%}
|
||||||
]=]
|
]=]
|
||||||
{% endfor -%}
|
{% endfor -%}
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
@@ -1,7 +1,3 @@
|
|||||||
{% if inj_enabled.css %}
|
|
||||||
{% include 'roles/sys-srv-web-inj-css/templates/location.conf.j2' %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if inj_enabled.logout %}
|
{% if inj_enabled.logout %}
|
||||||
{% include 'roles/web-svc-logout/templates/logout-proxy.conf.j2' %}
|
{% include 'roles/web-svc-logout/templates/logout-proxy.conf.j2' %}
|
||||||
{% endif %}
|
{% endif %}
|
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
This Ansible role ensures **consistent global theming** across all Nginx-served applications by injecting a unified `global.css` file.
|
This Ansible role ensures **consistent global theming** across all Nginx-served applications by injecting CSS files.
|
||||||
The role leverages [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/) to generate a dynamic, customizable color palette for light and dark mode, compatible with popular web tools like **Bootstrap**, **Keycloak**, **Nextcloud**, **Taiga**, **Mastodon**, and many more.
|
The role leverages [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/) to generate a dynamic, customizable color palette for light and dark mode, compatible with popular web tools like **Bootstrap**, **Keycloak**, **Nextcloud**, **Taiga**, **Mastodon**, and many more.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This role deploys a centralized global stylesheet (`global.css`) that overrides the default theming of web applications served via Nginx. It's optimized to run only once per deployment and generates a **cache-busting version number** based on file modification timestamps.
|
This role deploys a centralized global stylesheet that overrides the default theming of web applications served via Nginx. It's optimized to run only once per deployment and generates a **cache-busting version number** based on file modification timestamps.
|
||||||
It includes support for **dark mode**, **custom fonts**, and **extensive Bootstrap and UI component overrides**.
|
It includes support for **dark mode**, **custom fonts**, and **extensive Bootstrap and UI component overrides**.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -18,7 +18,7 @@ It makes all applications feel like part of the same ecosystem — visually and
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎨 **Dynamic Theming** via [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/)
|
- 🎨 **Dynamic Theming** via [`colorscheme-generator`](https://github.com/kevinveenbirkenbach/colorscheme-generator/)
|
||||||
- 📁 **Unified global.css** deployment for all Nginx applications
|
- 📁 **Unified CSS Base Configuration** deployment for all Nginx applications
|
||||||
- 🌒 **Dark mode support** out of the box
|
- 🌒 **Dark mode support** out of the box
|
||||||
- 🚫 **No duplication** – tasks run once per deployment
|
- 🚫 **No duplication** – tasks run once per deployment
|
||||||
- ⏱️ **Versioning logic** to bust browser cache
|
- ⏱️ **Versioning logic** to bust browser cache
|
21
roles/sys-front-inj-css/tasks/01_core.yml
Normal file
21
roles/sys-front-inj-css/tasks/01_core.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
- name: Include dependency 'srv-core'
|
||||||
|
include_role:
|
||||||
|
name: srv-core
|
||||||
|
when: run_once_srv_core is not defined
|
||||||
|
|
||||||
|
- name: Generate color palette with colorscheme-generator
|
||||||
|
set_fact:
|
||||||
|
color_palette: "{{ lookup('colorscheme', CSS_BASE_COLOR, count=CSS_COUNT, shades=CSS_SHADES) }}"
|
||||||
|
|
||||||
|
- name: Generate inverted color palette with colorscheme-generator
|
||||||
|
set_fact:
|
||||||
|
inverted_color_palette: "{{ lookup('colorscheme', CSS_BASE_COLOR, count=CSS_COUNT, shades=CSS_SHADES, invert_lightness=True) }}"
|
||||||
|
|
||||||
|
- name: Deploy default CSS files
|
||||||
|
template:
|
||||||
|
src: "{{ ['css', item ~ '.j2'] | path_join }}"
|
||||||
|
dest: "{{ [cdn_paths_all.shared.css, item] | path_join }}"
|
||||||
|
owner: "{{ NGINX.USER }}"
|
||||||
|
group: "{{ NGINX.USER }}"
|
||||||
|
mode: '0644'
|
||||||
|
loop: "{{ CSS_FILES }}"
|
25
roles/sys-front-inj-css/tasks/main.yml
Normal file
25
roles/sys-front-inj-css/tasks/main.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
- block:
|
||||||
|
- include_tasks: 01_core.yml
|
||||||
|
- include_tasks: utils/run_once.yml
|
||||||
|
when: run_once_sys_front_inj_css is not defined
|
||||||
|
|
||||||
|
- name: "Resolve optional app style.css source for '{{ application_id }}'"
|
||||||
|
vars:
|
||||||
|
app_role_dir: "{{ playbook_dir }}/roles/{{ application_id }}"
|
||||||
|
_app_style_src: >-
|
||||||
|
{{ lookup('first_found', {
|
||||||
|
'files': ['templates/style.css.j2','files/style.css'],
|
||||||
|
'paths': [app_role_dir]
|
||||||
|
}, errors='ignore') | default('', true) }}
|
||||||
|
set_fact:
|
||||||
|
app_style_src: "{{ _app_style_src }}"
|
||||||
|
app_style_present: "{{ _app_style_src | length > 0 }}"
|
||||||
|
|
||||||
|
- name: "Deploy per-app '{{ app_style_src }}' to '{{ css_app_dst }}'"
|
||||||
|
when: app_style_present
|
||||||
|
copy:
|
||||||
|
content: "{{ lookup('template', app_style_src) }}"
|
||||||
|
dest: "{{ css_app_dst }}"
|
||||||
|
owner: "{{ NGINX.USER }}"
|
||||||
|
group: "{{ NGINX.USER }}"
|
||||||
|
mode: '0644'
|
69
roles/sys-front-inj-css/templates/css/bootstrap.css.j2
Normal file
69
roles/sys-front-inj-css/templates/css/bootstrap.css.j2
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
/* Buttons (Background, Text, Border, and Shadow)
|
||||||
|
Now using a button background that is only slightly darker than the overall background */
|
||||||
|
html[native-dark-active] .btn, .btn {
|
||||||
|
background-color: var(--color-01-87);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-91), var(--color-01-95), var(--color-01-95));
|
||||||
|
color: var(--color-01-50);
|
||||||
|
border-color: var(--color-01-80);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation (Background and Text Colors) */
|
||||||
|
.navbar, .navbar-light, .navbar-dark, .navbar.bg-light {
|
||||||
|
background-color: var(--color-01-90);
|
||||||
|
/* New Gradient based on original background (90 -5, 90, 90 +1, 90 +5) */
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-85), var(--color-01-90), var(--color-01-91), var(--color-01-95));
|
||||||
|
color: var(--color-01-50);
|
||||||
|
border-color: var(--color-01-85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar a {
|
||||||
|
color: var(--color-01-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar a.dropdown-item {
|
||||||
|
color: var(--color-01-43);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards / Containers (Background, Border, and Shadow)
|
||||||
|
Cards now use a slightly lighter background and a bold, clear shadow */
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-01-90);
|
||||||
|
/* New Gradient based on original background (90 -5, 90, 90 +1, 90 +5) */
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-85), var(--color-01-90), var(--color-01-91), var(--color-01-95));
|
||||||
|
border-color: var(--color-01-85);
|
||||||
|
color: var(--color-01-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
color: var(--color-01-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown Menu and Submenu (Background, Text, and Shadow) */
|
||||||
|
.navbar .dropdown-menu,
|
||||||
|
.nav-item .dropdown-menu {
|
||||||
|
background-color: var(--color-01-80);
|
||||||
|
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
--bs-nav-link-hover-color: var(--color-01-17);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
color: var(--color-01-40);
|
||||||
|
background-color: var(--color-01-80);
|
||||||
|
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover,
|
||||||
|
.dropdown-item:focus {
|
||||||
|
background-color: var(--color-01-65);
|
||||||
|
/* New Gradient based on original background (65 -5, 65, 65 +1, 65 +5) */
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-60), var(--color-01-65), var(--color-01-66), var(--color-01-70));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
}
|
297
roles/sys-front-inj-css/templates/css/default.css.j2
Normal file
297
roles/sys-front-inj-css/templates/css/default.css.j2
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/***
|
||||||
|
|
||||||
|
Global Theming Styles – Color and Shadow Variables
|
||||||
|
|
||||||
|
HINT:
|
||||||
|
- Better overwritte CSS variables instead of individual elements.
|
||||||
|
- Don't use !important. If possible use a specific selector.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
{% if design.font.import_url %}
|
||||||
|
@import url('{{ design.font.import_url }}');
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
/* Auto-generated by colorscheme-generator */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
{% for var_name, color in color_palette.items() %}
|
||||||
|
{{ var_name }}: {{ color }};
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
{% for var_name, color in inverted_color_palette.items() %}
|
||||||
|
{{ var_name }}: {{ color }};
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root, ::after, ::before, ::backdrop {
|
||||||
|
/* For Dark Mode Plugin
|
||||||
|
* @See https://chromewebstore.google.com/detail/dark-mode/dmghijelimhndkbmpgbldicpogfkceaj
|
||||||
|
*/
|
||||||
|
--native-dark-accent-color: var(--color-01-60); /* was #a9a9a9 */
|
||||||
|
--native-dark-bg-color: var(--color-01-10); /* was #292929 */
|
||||||
|
--native-dark-bg-image-color: rgba(var(--color-01-rgb-01), 0.10); /* remains the same, or adjust if needed */
|
||||||
|
--native-dark-border-color: var(--color-01-40); /* was #555555 */
|
||||||
|
--native-dark-box-shadow: 0 0 0 1px rgb(var(--color-01-rgb-99), / 10%);
|
||||||
|
--native-dark-cite-color: var(--color-01-70); /* was #92de92 – you might adjust if a green tone is needed */
|
||||||
|
--native-dark-fill-color: var(--color-01-50); /* was #7d7d7d */
|
||||||
|
--native-dark-font-color: var(--color-01-95); /* was #dcdcdc */
|
||||||
|
--native-dark-link-color: var(--color-01-80); /* was #8db2e5 */
|
||||||
|
--native-dark-visited-link-color: var(--color-01-85); /* was #c76ed7 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootstrap Overrides (Color/Shadow Variables Only) */
|
||||||
|
:root {
|
||||||
|
--bs-black: var(--color-01-01); /* Original tone: Black (#000) */
|
||||||
|
--bs-white: var(--color-01-99); /* Original tone: White (#fff) */
|
||||||
|
--bs-gray: var(--color-01-50); /* Original tone: Gray (#6c757d) */
|
||||||
|
--bs-gray-dark: var(--color-01-20); /* Original tone: Dark Gray (#343a40) */
|
||||||
|
{% for i in range(1, 10) %}
|
||||||
|
{# @see https://chatgpt.com/share/67bcd94e-bb44-800f-bf63-06d1ae0f5096 #}
|
||||||
|
{% set gray = i * 100 %}
|
||||||
|
{% set color = 100 - i * 10 %}
|
||||||
|
--bs-gray-{{ gray }}: var(--color-01-{{ "%02d" % color }});
|
||||||
|
{% endfor %}
|
||||||
|
--bs-primary: var(--color-01-65); /* Original tone: Blue (#0d6efd) */
|
||||||
|
--bs-light: var(--color-01-95); /* Original tone: Light (#f8f9fa) */
|
||||||
|
--bs-dark: var(--color-01-10); /* Original tone: Dark (#212529) */
|
||||||
|
--bs-primary-rgb: var(--color-01-rgb-65); /* Original tone: Blue (13, 110, 253) */
|
||||||
|
--bs-secondary-rgb: var(--color-01-rgb-50); /* Original tone: Grayish (#6c757d / 108, 117, 125) */
|
||||||
|
--bs-light-rgb: var(--color-01-rgb-95); /* Original tone: Light (248, 249, 250) */
|
||||||
|
--bs-dark-rgb: var(--color-01-rgb-10); /* Original tone: Dark (33, 37, 41) */
|
||||||
|
--bs-white-rgb: var(--color-01-rgb-99); /* Original tone: White (255, 255, 255) */
|
||||||
|
--bs-black-rgb: var(--color-01-rgb-01); /* Original tone: Black (0, 0, 0) */
|
||||||
|
--bs-body-color-rgb: var(--color-01-rgb-10); /* Original tone: Dark (#212529 / 33, 37, 41) */
|
||||||
|
--bs-body-bg-rgb: var(--color-01-rgb-99); /* Original tone: White (#fff / 255, 255, 255) */
|
||||||
|
--bs-body-color: var(--color-01-10); /* Original tone: Dark (#212529) */
|
||||||
|
--bs-body-bg: var(--color-01-99); /* Original tone: White (#fff) */
|
||||||
|
--bs-border-color: var(--color-01-85); /* Original tone: Gray (#dee2e6) */
|
||||||
|
--bs-link-color: var(--color-01-65); /* Original tone: Blue (#0d6efd) */
|
||||||
|
--bs-link-hover-color: var(--color-01-60); /* Original tone: Darker Blue (#0a58ca) */
|
||||||
|
--bs-code-color: var(--color-01-55); /* Original tone: Pink (#d63384) */
|
||||||
|
--bs-highlight-bg: var(--color-01-93); /* Original tone: Light Yellow (#fff3cd) */
|
||||||
|
--bs-list-group-bg: var(--color-01-40);
|
||||||
|
--bs-emphasis-color: var(--color-01-01); /* Gemappt von #000 */
|
||||||
|
--bs-emphasis-color-rgb: var(--color-01-rgb-01); /* Gemappt von 0, 0, 0 */
|
||||||
|
--bs-secondary-color: rgba(var(--color-01-rgb-10), 0.75); /* Gemappt von rgba(33, 37, 41, 0.75) */
|
||||||
|
--bs-secondary-color-rgb: var(--color-01-rgb-10); /* Gemappt von 33, 37, 41 */
|
||||||
|
--bs-secondary-bg: var(--color-01-90); /* Gemappt von #e9ecef */
|
||||||
|
--bs-secondary-bg-rgb: var(--color-01-rgb-90); /* Gemappt von 233, 236, 239 */
|
||||||
|
--bs-tertiary-color: rgba(var(--color-01-rgb-10), 0.5); /* Gemappt von rgba(33, 37, 41, 0.5) */
|
||||||
|
--bs-tertiary-color-rgb: var(--color-01-rgb-10); /* Gemappt von 33, 37, 41 */
|
||||||
|
--bs-tertiary-bg: var(--color-01-95); /* Gemappt von #f8f9fa */
|
||||||
|
--bs-tertiary-bg-rgb: var(--color-01-rgb-95); /* Gemappt von 248, 249, 250 */
|
||||||
|
--bs-link-color-rgb: var(--color-01-rgb-65); /* Gemappt von 13, 110, 253 */
|
||||||
|
--bs-link-hover-color-rgb: var(--color-01-rgb-60); /* Gemappt von 10, 88, 202 */
|
||||||
|
--bs-highlight-color: var(--color-01-10); /* Gemappt von #212529 */
|
||||||
|
--bs-border-color-translucent: rgba(var(--color-01-rgb-01), 0.175); /* Gemappt von rgba(0, 0, 0, 0.175) */
|
||||||
|
--bs-focus-ring-color: rgba(var(--color-01-rgb-65), 0.25); /* Gemappt von rgba(13, 110, 253, 0.25) */
|
||||||
|
|
||||||
|
--bs-table-color: var(--bs-emphasis-color);
|
||||||
|
--bs-table-bg: var(--color-01-99); /* White (#fff) */
|
||||||
|
--bs-table-border-color: var(--color-01-99); /* White (#fff) */
|
||||||
|
--bs-table-striped-bg: var(--color-01-85); /* Light Gray (entspricht ca. #dee2e6) */
|
||||||
|
--bs-table-hover-color: var(--color-01-01); /* Black (#000) */
|
||||||
|
--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Defaults (Colors Only) */
|
||||||
|
body, html[native-dark-active] {
|
||||||
|
background-color: var(--color-01-93);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-93), var(--color-01-91), var(--color-01-95), var(--color-01-93));
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--color-01-40);
|
||||||
|
font-family: {{design.font.type}};
|
||||||
|
}
|
||||||
|
|
||||||
|
{# All links (applies to all anchor elements regardless of state) #}
|
||||||
|
a {
|
||||||
|
color: var(--color-01-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
{# Unvisited links (applies only to links that have not been visited) #}
|
||||||
|
a:link {
|
||||||
|
color: var(--color-01-55);
|
||||||
|
}
|
||||||
|
|
||||||
|
{# Visited links (applies only to links that have been visited) #}
|
||||||
|
a:visited {
|
||||||
|
color: var(--color-01-45);
|
||||||
|
}
|
||||||
|
|
||||||
|
{# Hover state (applies when the mouse pointer is over the link) #}
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-01-60);
|
||||||
|
}
|
||||||
|
|
||||||
|
{# Active state (applies during the time the link is being activated, e.g., on click) #}
|
||||||
|
a:active {
|
||||||
|
color: var(--color-01-65);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set default buttons transparent **/
|
||||||
|
html[native-dark-active] button, button{
|
||||||
|
background-color: var(--color-01-87);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover, .btn:hover {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Invalid state: when the input value fails validation criteria. Use danger color for error indication. #} */
|
||||||
|
input:invalid,
|
||||||
|
textarea:invalid,
|
||||||
|
select:invalid {
|
||||||
|
background-color: var(--color-01-01);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-01), var(--color-01-10));
|
||||||
|
/* Use Bootstrap danger color for error messages */
|
||||||
|
color: var(--bs-danger);
|
||||||
|
border-color: var(--color-01-20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Valid state: when the input value meets all validation criteria. Use success color for confirmation. #} */
|
||||||
|
input:valid,
|
||||||
|
textarea:valid,
|
||||||
|
select:valid {
|
||||||
|
background-color: var(--color-01-80);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-80), var(--color-01-90));
|
||||||
|
/* Use Bootstrap success color for confirmation messages */
|
||||||
|
color: var(--bs-success);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Required field: applied to elements that must be filled out by the user. Use warning color for emphasis. #} */
|
||||||
|
input:required,
|
||||||
|
textarea:required,
|
||||||
|
select:required {
|
||||||
|
background-color: var(--color-01-50);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-50), var(--color-01-60));
|
||||||
|
/* Use Bootstrap warning color to indicate a required field */
|
||||||
|
color: var(--bs-warning);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Optional field: applied to elements that are not mandatory. Use info color to denote additional information. #} */
|
||||||
|
input:optional,
|
||||||
|
textarea:optional,
|
||||||
|
select:optional {
|
||||||
|
background-color: var(--color-01-60);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-60), var(--color-01-70));
|
||||||
|
/* Use Bootstrap info color to indicate optional information */
|
||||||
|
color: var(--bs-info);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Read-only state: when an element is not editable by the user. #} */
|
||||||
|
input:read-only,
|
||||||
|
textarea:read-only,
|
||||||
|
select:read-only {
|
||||||
|
background-color: var(--color-01-80);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-90), var(--color-01-70));
|
||||||
|
color: var(--color-01-20);
|
||||||
|
border-color: var(--color-01-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Read-write state: when an element is editable by the user. #} */
|
||||||
|
input:read-write,
|
||||||
|
textarea:read-write,
|
||||||
|
select:read-write {
|
||||||
|
background-color: var(--color-01-70);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-80));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# In-range: for inputs with a defined range, when the value is within the allowed limits. #} */
|
||||||
|
input:in-range,
|
||||||
|
textarea:in-range,
|
||||||
|
select:in-range {
|
||||||
|
background-color: var(--color-01-70);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-70), var(--color-01-85));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Out-of-range: for inputs with a defined range, when the value falls outside the allowed limits. #} */
|
||||||
|
input:out-of-range,
|
||||||
|
textarea:out-of-range,
|
||||||
|
select:out-of-range {
|
||||||
|
background-color: var(--color-01-10);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-10), var(--color-01-30));
|
||||||
|
color: var(--color-01-10);
|
||||||
|
border-color: var(--color-01-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Placeholder-shown: when the input field is displaying its placeholder text. #} */
|
||||||
|
input:placeholder-shown,
|
||||||
|
textarea:placeholder-shown,
|
||||||
|
select:placeholder-shown {
|
||||||
|
background-color: var(--color-01-82);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-82), var(--color-01-90));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Focus state: when the element is focused by the user. #} */
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
background-color: var(--color-01-75);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-85));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Hover state: when the mouse pointer is over the element. #} */
|
||||||
|
input:hover,
|
||||||
|
textarea:hover,
|
||||||
|
select:hover {
|
||||||
|
background-color: var(--color-01-78);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-78), var(--color-01-88));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-65);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Active state: when the element is being activated (e.g., clicked). #} */
|
||||||
|
input:active,
|
||||||
|
textarea:active,
|
||||||
|
select:active {
|
||||||
|
background-color: var(--color-01-68);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-68), var(--color-01-78));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* {# Checked state: specifically for radio buttons and checkboxes when selected. #} */
|
||||||
|
input:checked {
|
||||||
|
background-color: var(--color-01-90);
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-90), var(--color-01-99));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
option {
|
||||||
|
background-color: var(--color-01-82);
|
||||||
|
color: var(--color-01-07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables (Borders and Header Colors) */
|
||||||
|
th, td {
|
||||||
|
border-color: var(--color-01-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background-color: var(--color-01-80);
|
||||||
|
/* New Gradient based on original background (80 -5, 80, 80 +1, 80 +5) */
|
||||||
|
background: linear-gradient({{ range(0, 361) | random }}deg, var(--color-01-75), var(--color-01-80), var(--color-01-81), var(--color-01-85));
|
||||||
|
color: var(--color-01-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings (Text Color) */
|
||||||
|
h1, h2, h3, h4, h5, h6, p{
|
||||||
|
color: var(--color-01-10);
|
||||||
|
}
|
8
roles/sys-front-inj-css/templates/head_sub.j2
Normal file
8
roles/sys-front-inj-css/templates/head_sub.j2
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% set __css_tpl_dir = [playbook_dir, 'roles', 'sys-front-inj-css', 'templates', 'css'] | path_join %}
|
||||||
|
|
||||||
|
{% for css_file in ['default.css','bootstrap.css'] %}
|
||||||
|
<link rel="stylesheet" href="{{ [ cdn_urls.shared.css, css_file, lookup('local_mtime_qs', [__css_tpl_dir, css_file ~ '.j2'] | path_join)] | url_join }}">
|
||||||
|
{% endfor %}
|
||||||
|
{% if app_style_present | bool %}
|
||||||
|
<link rel="stylesheet" href="{{ [ cdn_urls.role.release.css, 'style.css', lookup('local_mtime_qs', app_style_src)] | url_join }}">
|
||||||
|
{% endif %}
|
8
roles/sys-front-inj-css/vars/main.yml
Normal file
8
roles/sys-front-inj-css/vars/main.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Constants
|
||||||
|
CSS_FILES: ['default.css','bootstrap.css']
|
||||||
|
CSS_BASE_COLOR: "{{ design.css.colors.base }}"
|
||||||
|
CSS_COUNT: 7
|
||||||
|
CSS_SHADES: 100
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
css_app_dst: "{{ [cdn_paths_all.role.release.css, 'style.css'] | path_join }}"
|
7
roles/sys-front-inj-desktop/tasks/01_deploy.yml
Normal file
7
roles/sys-front-inj-desktop/tasks/01_deploy.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
- name: Deploy {{ INJ_DESKTOP_JS_FILE_NAME }}
|
||||||
|
template:
|
||||||
|
src: "{{ INJ_DESKTOP_JS_FILE_NAME }}.j2"
|
||||||
|
dest: "{{ INJ_DESKTOP_JS_FILE_DESTINATION }}"
|
||||||
|
owner: "{{ NGINX.USER }}"
|
||||||
|
group: "{{ NGINX.USER }}"
|
||||||
|
mode: '0644'
|
@@ -5,7 +5,7 @@
|
|||||||
when: run_once_srv_core is not defined
|
when: run_once_srv_core is not defined
|
||||||
- include_tasks: 01_deploy.yml
|
- include_tasks: 01_deploy.yml
|
||||||
- include_tasks: utils/run_once.yml
|
- include_tasks: utils/run_once.yml
|
||||||
when: run_once_sys_srv_web_inj_desktop is not defined
|
when: run_once_sys_front_inj_desktop is not defined
|
||||||
|
|
||||||
# --- Build tiny inline initializer (CSP-hashed) ---
|
# --- Build tiny inline initializer (CSP-hashed) ---
|
||||||
- name: "Load iFrame init code for '{{ application_id }}'"
|
- name: "Load iFrame init code for '{{ application_id }}'"
|
1
roles/sys-front-inj-desktop/templates/head_sub.j2
Normal file
1
roles/sys-front-inj-desktop/templates/head_sub.j2
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script src="{{ cdn_urls.shared.js }}/{{ INJ_DESKTOP_JS_FILE_NAME }}{{ lookup('local_mtime_qs', [playbook_dir, 'roles', 'sys-front-inj-desktop', 'templates', INJ_DESKTOP_JS_FILE_NAME ~ '.j2'] | path_join) }}"></script>
|
2
roles/sys-front-inj-desktop/vars/main.yml
Normal file
2
roles/sys-front-inj-desktop/vars/main.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
INJ_DESKTOP_JS_FILE_NAME: "iframe-handler.js"
|
||||||
|
INJ_DESKTOP_JS_FILE_DESTINATION: "{{ [cdn_paths_all.shared.js, INJ_DESKTOP_JS_FILE_NAME] | path_join }}"
|
@@ -5,7 +5,7 @@
|
|||||||
name: srv-core
|
name: srv-core
|
||||||
when: run_once_srv_core is not defined
|
when: run_once_srv_core is not defined
|
||||||
- include_tasks: utils/run_once.yml
|
- include_tasks: utils/run_once.yml
|
||||||
when: run_once_sys_srv_web_inj_javascript is not defined
|
when: run_once_sys_front_inj_javascript is not defined
|
||||||
|
|
||||||
- name: "Load JavaScript code for '{{ application_id }}'"
|
- name: "Load JavaScript code for '{{ application_id }}'"
|
||||||
set_fact:
|
set_fact:
|
@@ -1,10 +1,10 @@
|
|||||||
# sys-srv-web-inj-logout
|
# sys-front-inj-logout
|
||||||
|
|
||||||
This role injects a catcher that intercepts all logout elements in HTML pages served by Nginx and redirects them to a centralized logout endpoint via JavaScript.
|
This role injects a catcher that intercepts all logout elements in HTML pages served by Nginx and redirects them to a centralized logout endpoint via JavaScript.
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
The `sys-srv-web-inj-logout` Ansible role automatically embeds a lightweight JavaScript snippet into your web application's HTML responses. This script identifies logout links, buttons, forms, and other elements, overrides their target URLs, and ensures users are redirected to a central OIDC logout endpoint, providing a consistent single sign‑out experience.
|
The `sys-front-inj-logout` Ansible role automatically embeds a lightweight JavaScript snippet into your web application's HTML responses. This script identifies logout links, buttons, forms, and other elements, overrides their target URLs, and ensures users are redirected to a central OIDC logout endpoint, providing a consistent single sign‑out experience.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
@@ -1,6 +1,6 @@
|
|||||||
galaxy_info:
|
galaxy_info:
|
||||||
author: "Kevin Veen‑Birkenbach"
|
author: "Kevin Veen‑Birkenbach"
|
||||||
role_name: "sys-srv-web-inj-logout"
|
role_name: "sys-front-inj-logout"
|
||||||
description: >
|
description: >
|
||||||
Injects a JavaScript snippet via Nginx sub_filter that intercepts all logout actions
|
Injects a JavaScript snippet via Nginx sub_filter that intercepts all logout actions
|
||||||
(links, buttons, forms) and redirects users to a centralized OIDC logout endpoint.
|
(links, buttons, forms) and redirects users to a centralized OIDC logout endpoint.
|
||||||
@@ -21,4 +21,4 @@ galaxy_info:
|
|||||||
Kevin Veen‑Birkenbach Consulting & Coaching Solutions https://www.veen.world
|
Kevin Veen‑Birkenbach Consulting & Coaching Solutions https://www.veen.world
|
||||||
repository: "https://s.infinito.nexus/code"
|
repository: "https://s.infinito.nexus/code"
|
||||||
issue_tracker_url: "https://s.infinito.nexus/issues"
|
issue_tracker_url: "https://s.infinito.nexus/issues"
|
||||||
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-srv-web-inj-logout"
|
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-logout"
|
@@ -1,8 +1,8 @@
|
|||||||
- block:
|
- block:
|
||||||
- include_tasks: 01_core.yml
|
- include_tasks: 01_core.yml
|
||||||
- set_fact:
|
- set_fact:
|
||||||
run_once_sys_srv_web_inj_logout: true
|
run_once_sys_front_inj_logout: true
|
||||||
when: run_once_sys_srv_web_inj_logout is not defined
|
when: run_once_sys_front_inj_logout is not defined
|
||||||
|
|
||||||
- name: "Load logout code for '{{ application_id }}'"
|
- name: "Load logout code for '{{ application_id }}'"
|
||||||
set_fact:
|
set_fact:
|
1
roles/sys-front-inj-logout/templates/head_sub.j2
Normal file
1
roles/sys-front-inj-logout/templates/head_sub.j2
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<script src="{{ cdn_urls.shared.js }}/{{ INJ_LOGOUT_JS_FILE_NAME }}{{ lookup('local_mtime_qs', [playbook_dir, 'roles', 'sys-front-inj-logout', 'templates', INJ_LOGOUT_JS_FILE_NAME ~ '.j2'] | path_join) }}"></script>
|
2
roles/sys-front-inj-logout/vars/main.yml
Normal file
2
roles/sys-front-inj-logout/vars/main.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
INJ_LOGOUT_JS_FILE_NAME: "logout.js"
|
||||||
|
INJ_LOGOUT_JS_DESTINATION: "{{ [cdn_paths_all.shared.js, INJ_LOGOUT_JS_FILE_NAME] | path_join }}"
|
@@ -13,7 +13,7 @@ galaxy_info:
|
|||||||
- analytics
|
- analytics
|
||||||
repository: "https://s.infinito.nexus/code"
|
repository: "https://s.infinito.nexus/code"
|
||||||
issue_tracker_url: "https://s.infinito.nexus/issues"
|
issue_tracker_url: "https://s.infinito.nexus/issues"
|
||||||
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-srv-web-inj-matomo"
|
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-front-inj-matomo"
|
||||||
min_ansible_version: "2.9"
|
min_ansible_version: "2.9"
|
||||||
platforms:
|
platforms:
|
||||||
- name: Any
|
- name: Any
|
@@ -4,7 +4,7 @@
|
|||||||
name: srv-core
|
name: srv-core
|
||||||
when: run_once_srv_core is not defined
|
when: run_once_srv_core is not defined
|
||||||
- include_tasks: utils/run_once.yml
|
- include_tasks: utils/run_once.yml
|
||||||
when: run_once_sys_srv_web_inj_matomo is not defined
|
when: run_once_sys_front_inj_matomo is not defined
|
||||||
|
|
||||||
- name: "Relevant variables for role: {{ role_path | basename }}"
|
- name: "Relevant variables for role: {{ role_path | basename }}"
|
||||||
debug:
|
debug:
|
@@ -1,4 +1,4 @@
|
|||||||
SYS_SERVICE_ALL_ENABLED: "{{ MODE_DEBUG }}"
|
SYS_SERVICE_ALL_ENABLED: "{{ MODE_DEBUG | bool }}"
|
||||||
SYS_SERVICE_DEFAULT_STATE: "{{ 'restarted' if MODE_DEBUG else omit }}"
|
SYS_SERVICE_DEFAULT_STATE: "{{ 'restarted' if MODE_DEBUG else omit }}"
|
||||||
SYS_SERVICE_DEFAULT_RUNTIME: "86400s" # Maximum total runtime a service is allowed to run before being stopped
|
SYS_SERVICE_DEFAULT_RUNTIME: "86400s" # Maximum total runtime a service is allowed to run before being stopped
|
||||||
SYS_SERVICE_SUPPRESS_FLUSH: [] # Services where the flush should be suppressed
|
SYS_SERVICE_SUPPRESS_FLUSH: [] # Services where the flush should be suppressed
|
@@ -46,5 +46,5 @@
|
|||||||
command: /bin/true
|
command: /bin/true
|
||||||
notify: refresh systemctl service
|
notify: refresh systemctl service
|
||||||
when: not system_service_uses_at
|
when: not system_service_uses_at
|
||||||
when: (SYS_SERVICE_ALL_ENABLED | bool or system_force_flush | bool)
|
when: system_force_flush | bool
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ system_service_role_dir: "{{ [ playbook_dir, 'roles', system_service_role_
|
|||||||
system_service_script_dir: "{{ [ PATH_SYSTEMCTL_SCRIPTS, system_service_id ] | path_join }}"
|
system_service_script_dir: "{{ [ PATH_SYSTEMCTL_SCRIPTS, system_service_id ] | path_join }}"
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
system_force_flush: false # When set to true it activates the flushing of services :)
|
system_force_flush: "{{ SYS_SERVICE_ALL_ENABLED | bool }}" # When set to true it activates the flushing of services. defaults to SYS_SERVICE_ALL_ENABLED
|
||||||
system_service_suppress_flush: "{{ (system_service_id in SYS_SERVICE_SUPPRESS_FLUSH) | bool }}" # When set to true it suppresses the flushing of services
|
system_service_suppress_flush: "{{ (system_service_id in SYS_SERVICE_SUPPRESS_FLUSH) | bool }}" # When set to true it suppresses the flushing of services
|
||||||
system_service_copy_files: true # When set to false file copying will be skipped
|
system_service_copy_files: true # When set to false file copying will be skipped
|
||||||
system_service_timer_enabled: false # When set to true timer will be loaded
|
system_service_timer_enabled: false # When set to true timer will be loaded
|
||||||
|
@@ -1,29 +0,0 @@
|
|||||||
- name: Include dependency 'srv-core'
|
|
||||||
include_role:
|
|
||||||
name: srv-core
|
|
||||||
when: run_once_srv_core is not defined
|
|
||||||
|
|
||||||
- name: Generate color palette with colorscheme-generator
|
|
||||||
set_fact:
|
|
||||||
color_palette: "{{ lookup('colorscheme', global_css_base_color, count=global_css_count, shades=global_css_shades) }}"
|
|
||||||
|
|
||||||
- name: Generate inverted color palette with colorscheme-generator
|
|
||||||
set_fact:
|
|
||||||
inverted_color_palette: "{{ lookup('colorscheme', global_css_base_color, count=global_css_count, shades=global_css_shades, invert_lightness=True) }}"
|
|
||||||
|
|
||||||
- name: Deploy global.css
|
|
||||||
template:
|
|
||||||
src: global.css.j2
|
|
||||||
dest: "{{ global_css_destination }}"
|
|
||||||
owner: "{{ NGINX.USER }}"
|
|
||||||
group: "{{ NGINX.USER }}"
|
|
||||||
mode: '0644'
|
|
||||||
|
|
||||||
- name: Get stat for global.css
|
|
||||||
stat:
|
|
||||||
path: "{{ global_css_destination }}"
|
|
||||||
register: global_css_stat
|
|
||||||
|
|
||||||
- name: Set global_css_version
|
|
||||||
set_fact:
|
|
||||||
global_css_version: "{{ global_css_stat.stat.mtime }}"
|
|
@@ -1,4 +0,0 @@
|
|||||||
- block:
|
|
||||||
- include_tasks: 01_core.yml
|
|
||||||
- include_tasks: utils/run_once.yml
|
|
||||||
when: run_once_sys_srv_web_inj_css is not defined
|
|
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="/global.css?version={{ global_css_version }}">
|
|
@@ -1,3 +0,0 @@
|
|||||||
location = /global.css {
|
|
||||||
root {{ NGINX.DIRECTORIES.DATA.CDN }};
|
|
||||||
}
|
|
@@ -1,4 +0,0 @@
|
|||||||
global_css_destination: "{{ NGINX.DIRECTORIES.DATA.CDN }}global.css"
|
|
||||||
global_css_base_color: "{{ design.css.colors.base }}"
|
|
||||||
global_css_count: 7
|
|
||||||
global_css_shades: 100
|
|
@@ -1,16 +0,0 @@
|
|||||||
- name: Deploy iframe-handler.js
|
|
||||||
template:
|
|
||||||
src: iframe-handler.js.j2
|
|
||||||
dest: "{{ INJ_DESKTOP_JS_FILE_DESTINATION }}"
|
|
||||||
owner: "{{ NGINX.USER }}"
|
|
||||||
group: "{{ NGINX.USER }}"
|
|
||||||
mode: '0644'
|
|
||||||
|
|
||||||
- name: Get stat for iframe-handler.js
|
|
||||||
stat:
|
|
||||||
path: "{{ INJ_DESKTOP_JS_FILE_DESTINATION }}"
|
|
||||||
register: inj_port_ui_js_stat
|
|
||||||
|
|
||||||
- name: Set inj_port_ui_js_version
|
|
||||||
set_fact:
|
|
||||||
inj_port_ui_js_version: "{{ inj_port_ui_js_stat.stat.mtime }}"
|
|
@@ -1 +0,0 @@
|
|||||||
<script src="{{ domains | get_url('web-svc-cdn', WEB_PROTOCOL) }}/{{ INJ_DESKTOP_JS_FILE_NAME }}?{{ inj_port_ui_js_version }}"></script>
|
|
@@ -1,2 +0,0 @@
|
|||||||
INJ_DESKTOP_JS_FILE_NAME: "iframe-handler.js"
|
|
||||||
INJ_DESKTOP_JS_FILE_DESTINATION: "{{ [ NGINX.DIRECTORIES.DATA.CDN, INJ_DESKTOP_JS_FILE_NAME ] | path_join }}"
|
|
@@ -1 +0,0 @@
|
|||||||
<script src="{{ domains | get_url('web-svc-cdn', WEB_PROTOCOL) }}/{{ INJ_LOGOUT_JS_FILE_NAME }}?{{ INJ_LOGOUT_JS_VERSION }}"></script>
|
|
@@ -1,2 +0,0 @@
|
|||||||
INJ_LOGOUT_JS_FILE_NAME: "logout.js"
|
|
||||||
INJ_LOGOUT_JS_DESTINATION: "{{ [ NGINX.DIRECTORIES.DATA.CDN, INJ_LOGOUT_JS_FILE_NAME ] | path_join }}"
|
|
@@ -8,7 +8,7 @@ This role bootstraps **per-domain Nginx configuration**: it requests TLS certifi
|
|||||||
|
|
||||||
A higher-level orchestration wrapper, *sys-stk-front-proxy* ties together several lower-level roles:
|
A higher-level orchestration wrapper, *sys-stk-front-proxy* ties together several lower-level roles:
|
||||||
|
|
||||||
1. **`sys-srv-web-inj-compose`** – applies global tweaks and includes.
|
1. **`sys-front-inj-all`** – applies global tweaks and includes.
|
||||||
2. **`sys-svc-certs`** – obtains Let’s Encrypt certificates.
|
2. **`sys-svc-certs`** – obtains Let’s Encrypt certificates.
|
||||||
3. **Domain template deployment** – copies a Jinja2 vHost from *srv-proxy-core*.
|
3. **Domain template deployment** – copies a Jinja2 vHost from *srv-proxy-core*.
|
||||||
4. **`web-app-oauth2-proxy`** *(optional)* – protects the site with OAuth2.
|
4. **`web-app-oauth2-proxy`** *(optional)* – protects the site with OAuth2.
|
||||||
|
@@ -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"
|
19
roles/sys-svc-cdn/README.md
Normal file
19
roles/sys-svc-cdn/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# sys-svc-cdn
|
||||||
|
|
||||||
|
CDN helper role for building a consistent asset tree, URLs, and on-disk layout.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Provides compact filters and defaults to define CDN paths, turn them into public URLs, collect required directories, and prepare the filesystem (including a `latest` release link).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Defines a per-role CDN structure under `roles/<application_id>/<version>` plus shared and vendor areas. Exposes ready-to-use variables (`cdn`, `cdn_dirs`, `cdn_urls`) and ensures directories exist. Optionally links the current release to `latest`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* Jinja filters: `cdn_paths`, `cdn_urls`, `cdn_dirs`
|
||||||
|
* Variables: `CDN_ROOT`, `CDN_VERSION`, `CDN_BASE_URL`, `cdn`, `cdn_dirs`, `cdn_urls`
|
||||||
|
* Creates shared/vendor/release directories
|
||||||
|
* Maintains `roles/<id>/latest` symlink (when version ≠ `latest`)
|
||||||
|
* Plays nicely with `web-svc-cdn` without circular inclusion
|
17
roles/sys-svc-cdn/filter_plugins/cdn_dirs.py
Normal file
17
roles/sys-svc-cdn/filter_plugins/cdn_dirs.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
def cdn_dirs(tree):
|
||||||
|
out = set()
|
||||||
|
def walk(v):
|
||||||
|
if isinstance(v, dict):
|
||||||
|
for x in v.values(): walk(x)
|
||||||
|
elif isinstance(v, list):
|
||||||
|
for x in v: walk(x)
|
||||||
|
elif isinstance(v, str) and os.path.isabs(v):
|
||||||
|
out.add(v)
|
||||||
|
walk(tree)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {"cdn_dirs": cdn_dirs}
|
46
roles/sys-svc-cdn/filter_plugins/cdn_paths.py
Normal file
46
roles/sys-svc-cdn/filter_plugins/cdn_paths.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
def cdn_paths(cdn_root, application_id, version):
|
||||||
|
"""
|
||||||
|
Build a structured dictionary of all CDN paths for a given application.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cdn_root (str): Base CDN root, e.g. /var/www/cdn
|
||||||
|
application_id (str): Role/application identifier
|
||||||
|
version (str): Release version string (default: current UTC timestamp)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Hierarchical CDN path structure
|
||||||
|
"""
|
||||||
|
cdn_root = os.path.abspath(cdn_root)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"root": cdn_root,
|
||||||
|
"shared": {
|
||||||
|
"root": os.path.join(cdn_root, "_shared"),
|
||||||
|
"css": os.path.join(cdn_root, "_shared", "css"),
|
||||||
|
"js": os.path.join(cdn_root, "_shared", "js"),
|
||||||
|
"img": os.path.join(cdn_root, "_shared", "img"),
|
||||||
|
"fonts": os.path.join(cdn_root, "_shared", "fonts"),
|
||||||
|
},
|
||||||
|
"vendor": os.path.join(cdn_root, "vendor"),
|
||||||
|
"role": {
|
||||||
|
"id": application_id,
|
||||||
|
"root": os.path.join(cdn_root, "roles", application_id),
|
||||||
|
"version": version,
|
||||||
|
"release": {
|
||||||
|
"root": os.path.join(cdn_root, "roles", application_id, version),
|
||||||
|
"css": os.path.join(cdn_root, "roles", application_id, version, "css"),
|
||||||
|
"js": os.path.join(cdn_root, "roles", application_id, version, "js"),
|
||||||
|
"img": os.path.join(cdn_root, "roles", application_id, version, "img"),
|
||||||
|
"fonts": os.path.join(cdn_root, "roles", application_id, version, "fonts"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"cdn_paths": cdn_paths,
|
||||||
|
}
|
60
roles/sys-svc-cdn/filter_plugins/cdn_urls.py
Normal file
60
roles/sys-svc-cdn/filter_plugins/cdn_urls.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# filter_plugins/cdn_urls.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
def _to_url_tree(obj, cdn_root, base_url):
|
||||||
|
"""
|
||||||
|
Recursively walk a nested dict and replace any string paths under cdn_root
|
||||||
|
with URLs based on base_url. Non-path strings (e.g. role.id, role.version)
|
||||||
|
are left untouched.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _to_url_tree(v, cdn_root, base_url) for k, v in obj.items()}
|
||||||
|
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_to_url_tree(v, cdn_root, base_url) for v in obj]
|
||||||
|
|
||||||
|
if isinstance(obj, str):
|
||||||
|
# Normalize inputs
|
||||||
|
norm_root = os.path.abspath(cdn_root)
|
||||||
|
norm_val = os.path.abspath(obj)
|
||||||
|
|
||||||
|
if norm_val.startswith(norm_root):
|
||||||
|
# Compute path relative to CDN root and map to URL
|
||||||
|
rel = os.path.relpath(norm_val, norm_root)
|
||||||
|
# Handle root itself ('.') → empty path
|
||||||
|
if rel == ".":
|
||||||
|
rel = ""
|
||||||
|
# Always forward slashes for URLs
|
||||||
|
rel_url = rel.replace(os.sep, "/")
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
return f"{base}/{rel_url}" if rel_url else f"{base}/"
|
||||||
|
# Non-CDN string → leave as-is (e.g., role.id / role.version)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# Any other type → return as-is
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def cdn_urls(cdn_dict, base_url):
|
||||||
|
"""
|
||||||
|
Create a URL-structured dict from a CDN path dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cdn_dict (dict): output of cdn_paths(...), containing absolute paths
|
||||||
|
base_url (str): CDN base URL, e.g. https://cdn.example.com
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: same shape as cdn_dict, but with URLs instead of filesystem paths
|
||||||
|
for any strings pointing under cdn_dict['root'].
|
||||||
|
Keys like role.id and role.version remain strings as-is.
|
||||||
|
"""
|
||||||
|
if not isinstance(cdn_dict, dict) or "root" not in cdn_dict:
|
||||||
|
raise ValueError("cdn_urls expects a dict from cdn_paths with a 'root' key")
|
||||||
|
return _to_url_tree(cdn_dict, cdn_dict["root"], base_url)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"cdn_urls": cdn_urls,
|
||||||
|
}
|
24
roles/sys-svc-cdn/meta/main.yml
Normal file
24
roles/sys-svc-cdn/meta/main.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
galaxy_info:
|
||||||
|
author: "Kevin Veen-Birkenbach"
|
||||||
|
description: "Prepares and manages the CDN folder structure with shared, vendor, and per-role release directories."
|
||||||
|
license: "Infinito.Nexus NonCommercial License"
|
||||||
|
license_url: "https://s.infinito.nexus/license"
|
||||||
|
company: |
|
||||||
|
Kevin Veen-Birkenbach
|
||||||
|
Consulting & Coaching Solutions
|
||||||
|
https://www.veen.world
|
||||||
|
min_ansible_version: "2.9"
|
||||||
|
platforms:
|
||||||
|
- name: Any
|
||||||
|
versions:
|
||||||
|
- all
|
||||||
|
galaxy_tags:
|
||||||
|
- cdn
|
||||||
|
- nginx
|
||||||
|
- assets
|
||||||
|
- roles
|
||||||
|
- versioning
|
||||||
|
repository: "https://s.infinito.nexus/code"
|
||||||
|
issue_tracker_url: "https://s.infinito.nexus/issues"
|
||||||
|
documentation: "https://s.infinito.nexus/code/tree/main/roles/sys-svc-cdn"
|
30
roles/sys-svc-cdn/tasks/01_core.yml
Normal file
30
roles/sys-svc-cdn/tasks/01_core.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
- name: "Load CDN for '{{ domain }}'"
|
||||||
|
include_role:
|
||||||
|
name: web-svc-cdn
|
||||||
|
public: false
|
||||||
|
when:
|
||||||
|
- application_id != 'web-svc-cdn'
|
||||||
|
- run_once_web_svc_cdn is not defined
|
||||||
|
|
||||||
|
- name: Overwritte CDN handlers with neutral handlers
|
||||||
|
ansible.builtin.include_tasks: "{{ [ playbook_dir, 'tasks/utils/load_handlers.yml'] | path_join }}"
|
||||||
|
loop:
|
||||||
|
- svc-prx-openresty
|
||||||
|
- docker-compose
|
||||||
|
loop_control:
|
||||||
|
label: "{{ item }}"
|
||||||
|
vars:
|
||||||
|
handler_role_name: "{{ item }}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Only-once creations (shared root and vendor)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- name: Ensure shared root and vendor exist (run once)
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ NGINX.USER }}"
|
||||||
|
group: "{{ NGINX.USER }}"
|
||||||
|
mode: "0755"
|
||||||
|
loop: "{{ CDN_DIRS_GLOBAL }}"
|
||||||
|
- include_tasks: utils/run_once.yml
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user