feat(frontend): rename inj roles to sys-front-*, add sys-svc-cdn, cache-busting lookup

Introduce sys-svc-cdn (cdn_paths/cdn_urls/cdn_dirs) and ensure CDN directories + latest symlink.

Rename sys-srv-web-inj-* → sys-front-inj-*; update includes/templates; serve shared/per-app CSS & JS via CDN.

Add lookup_plugins/local_mtime_qs.py for mtime-based cache busting; split CSS into default.css/bootstrap.css + optional per-app style.css.

CSP: use style-src-elem; drop unsafe-inline for styles. Services: fix SYS_SERVICE_ALL_ENABLED bool and controlled flush.

BREAKING CHANGE: role names changed; replace includes and references accordingly.

Conversation: https://chatgpt.com/share/68b55494-9ec4-800f-b559-44707029141d
This commit is contained in:
2025-09-01 10:10:23 +02:00
parent 3f8e7c1733
commit 231fd567b3
123 changed files with 1789 additions and 1393 deletions

View File

@@ -0,0 +1,50 @@
import os
import sys
import tempfile
import time
import unittest
# ensure repo root on path
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
sys.path.insert(0, ROOT)
from ansible.errors import AnsibleError # type: ignore
from lookup_plugins.local_mtime_qs import LookupModule
class TestLocalMtimeQs(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmpdir.name, "file.css")
with open(self.path, "w", encoding="utf-8") as f:
f.write("body{}")
# set stable mtime
self.mtime = int(time.time()) - 123
os.utime(self.path, (self.mtime, self.mtime))
def tearDown(self):
self.tmpdir.cleanup()
def test_single_path_qs_default(self):
res = LookupModule().run([self.path])
self.assertEqual(res, [f"?version={self.mtime}"])
def test_single_path_epoch(self):
res = LookupModule().run([self.path], mode="epoch")
self.assertEqual(res, [str(self.mtime)])
def test_multiple_paths(self):
path2 = os.path.join(self.tmpdir.name, "a.js")
with open(path2, "w", encoding="utf-8") as f:
f.write("// js")
os.utime(path2, (self.mtime + 1, self.mtime + 1))
res = LookupModule().run([self.path, path2], param="v")
self.assertEqual(res, [f"?v={self.mtime}", f"?v={self.mtime + 1}"])
def test_missing_raises(self):
with self.assertRaises(AnsibleError):
LookupModule().run([os.path.join(self.tmpdir.name, "nope.css")])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,4 +1,3 @@
# tests/unit/roles/srv-web-inj-compose/filter_plugins/test_inj_enabled.py
import importlib.util
from importlib import import_module
from pathlib import Path
@@ -8,7 +7,7 @@ import unittest
THIS_FILE = Path(__file__)
def find_repo_root(start: Path) -> Path:
target_rel = Path("roles") / "sys-srv-web-inj-compose" / "filter_plugins" / "inj_enabled.py"
target_rel = Path("roles") / "sys-front-inj-all" / "filter_plugins" / "inj_enabled.py"
cur = start
for _ in range(12):
if (cur / target_rel).is_file():
@@ -17,7 +16,7 @@ def find_repo_root(start: Path) -> Path:
return start.parents[6]
REPO_ROOT = find_repo_root(THIS_FILE)
PLUGIN_PATH = REPO_ROOT / "roles" / "sys-srv-web-inj-compose" / "filter_plugins" / "inj_enabled.py"
PLUGIN_PATH = REPO_ROOT / "roles" / "sys-front-inj-all" / "filter_plugins" / "inj_enabled.py"
# Ensure 'module_utils' is importable under its canonical package name
if str(REPO_ROOT) not in sys.path:

View File

@@ -1,6 +1,6 @@
# tests/unit/roles/sys-srv-web-inj-compose/filter_plugins/test_inj_snippets.py
# tests/unit/roles/sys-front-inj-all/filter_plugins/test_inj_snippets.py
"""
Unit tests for roles/sys-srv-web-inj-compose/filter_plugins/inj_snippets.py
Unit tests for roles/sys-front-inj-all/filter_plugins/inj_snippets.py
- Uses tempfile.TemporaryDirectory for an isolated roles/ tree.
- Loads inj_snippets.py by absolute path (no sys.path issues).
@@ -22,7 +22,7 @@ class TestInjSnippets(unittest.TestCase):
cls.test_dir = os.path.dirname(__file__)
root = cls.test_dir
inj_rel = os.path.join(
"roles", "sys-srv-web-inj-compose", "filter_plugins", "inj_snippets.py"
"roles", "sys-front-inj-all", "filter_plugins", "inj_snippets.py"
)
while True:
@@ -67,7 +67,7 @@ class TestInjSnippets(unittest.TestCase):
@classmethod
def _mkrole(cls, feature, head=False, body=False):
role_dir = os.path.join(cls.roles_dir, f"sys-srv-web-inj-{feature}")
role_dir = os.path.join(cls.roles_dir, f"sys-front-inj-{feature}")
tmpl_dir = os.path.join(role_dir, "templates")
os.makedirs(tmpl_dir, exist_ok=True)
if head:

View File

View File

@@ -0,0 +1,108 @@
import os
import tempfile
import unittest
import importlib.util
HERE = os.path.abspath(os.path.dirname(__file__))
def _find_repo_root(start_dir: str, probe_parts: list[str]) -> str:
"""
Walk upwards from start_dir until a path joined with probe_parts exists.
Returns the directory considered the repo root.
"""
cur = os.path.abspath(start_dir)
for _ in range(15): # plenty of headroom
candidate = os.path.join(cur, *probe_parts)
if os.path.exists(candidate):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
raise RuntimeError(
f"Could not locate {'/'.join(probe_parts)} starting from {start_dir}"
)
PROBE = ["roles", "sys-svc-cdn", "filter_plugins", "cdn_paths.py"]
ROOT = _find_repo_root(HERE, PROBE)
def _load_module(mod_name: str, rel_path_from_root: str):
"""Load a python module from an absolute file path (hyphen-safe)."""
path = os.path.join(ROOT, rel_path_from_root)
if not os.path.isfile(path):
raise FileNotFoundError(path)
spec = importlib.util.spec_from_file_location(mod_name, path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader, f"Cannot load spec for {path}"
spec.loader.exec_module(module) # type: ignore[attr-defined]
return module
cdn_paths_mod = _load_module(
"cdn_paths_mod", os.path.join("roles", "sys-svc-cdn", "filter_plugins", "cdn_paths.py")
)
cdn_urls_mod = _load_module(
"cdn_urls_mod", os.path.join("roles", "sys-svc-cdn", "filter_plugins", "cdn_urls.py")
)
cdn_dirs_mod = _load_module(
"cdn_dirs_mod", os.path.join("roles", "sys-svc-cdn", "filter_plugins", "cdn_dirs.py")
)
class TestCdnPathsUrlsDirs(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = self.tmp.name
self.app = "web-app-desktop"
self.ver = "20250101"
self.cdn_paths = cdn_paths_mod.cdn_paths
self.cdn_urls = cdn_urls_mod.cdn_urls
self.cdn_dirs = cdn_dirs_mod.cdn_dirs
self.tree = self.cdn_paths(self.root, self.app, self.ver)
def tearDown(self):
self.tmp.cleanup()
# ---- cdn_paths ----
def test_paths_shape_and_values(self):
t = self.tree
self.assertTrue(os.path.isabs(t["root"]))
self.assertEqual(t["role"]["id"], self.app)
self.assertEqual(t["role"]["version"], self.ver)
self.assertTrue(t["shared"]["css"].endswith(os.path.join("_shared", "css")))
self.assertTrue(
t["role"]["release"]["css"].endswith(os.path.join(self.app, self.ver, "css"))
)
# ---- cdn_urls ----
def test_urls_mapping_and_root_trailing_slash(self):
base = "https://cdn.example.com"
urls = self.cdn_urls(self.tree, base)
# Non-path strings remain untouched
self.assertEqual(urls["role"]["id"], self.app)
self.assertEqual(urls["role"]["version"], self.ver)
# Paths are mapped to URLs
self.assertTrue(urls["shared"]["js"].startswith(base + "/"))
self.assertTrue(urls["vendor"].startswith(base + "/vendor"))
# Root always ends with '/'
self.assertEqual(urls["root"], base.rstrip("/") + "/")
def test_urls_invalid_input_raises(self):
with self.assertRaises(ValueError):
self.cdn_urls({}, "https://cdn.example.com")
with self.assertRaises(ValueError):
self.cdn_urls("nope", "https://cdn.example.com") # type: ignore[arg-type]
# ---- cdn_dirs ----
def test_dirs_collects_all_abs_dirs_sorted_unique(self):
dirs = self.cdn_dirs(self.tree)
self.assertIn(os.path.join(self.root, "_shared", "css"), dirs)
self.assertIn(os.path.join(self.root, "roles", self.app, self.ver, "img"), dirs)
self.assertEqual(dirs, sorted(dirs))
self.assertEqual(len(dirs), len(set(dirs)))
if __name__ == "__main__":
unittest.main()