diff --git a/roles/web-app-nextcloud/meta/addons/files_bpm.yml b/roles/web-app-nextcloud/meta/addons/files_bpm.yml index fb3f2e7797..352ca2a594 100644 --- a/roles/web-app-nextcloud/meta/addons/files_bpm.yml +++ b/roles/web-app-nextcloud/meta/addons/files_bpm.yml @@ -1,5 +1,5 @@ --- enabled: true -required: true +required: false mechanism: plugin source: upstream diff --git a/roles/web-app-nextcloud/meta/addons/news.yml b/roles/web-app-nextcloud/meta/addons/news.yml index fb3f2e7797..352ca2a594 100644 --- a/roles/web-app-nextcloud/meta/addons/news.yml +++ b/roles/web-app-nextcloud/meta/addons/news.yml @@ -1,5 +1,5 @@ --- enabled: true -required: true +required: false mechanism: plugin source: upstream diff --git a/roles/web-app-nextcloud/meta/addons/recognize.yml b/roles/web-app-nextcloud/meta/addons/recognize.yml index fb3f2e7797..352ca2a594 100644 --- a/roles/web-app-nextcloud/meta/addons/recognize.yml +++ b/roles/web-app-nextcloud/meta/addons/recognize.yml @@ -1,5 +1,5 @@ --- enabled: true -required: true +required: false mechanism: plugin source: upstream diff --git a/roles/web-app-nextcloud/meta/addons/spreed.yml b/roles/web-app-nextcloud/meta/addons/spreed.yml index 176bdb81c7..31f088c09f 100644 --- a/roles/web-app-nextcloud/meta/addons/spreed.yml +++ b/roles/web-app-nextcloud/meta/addons/spreed.yml @@ -18,6 +18,7 @@ config: | to_json }}" - appid: "spreed" configkey: "stun_servers" + type: array configvalue: >- {{ [ @@ -30,6 +31,7 @@ config: }} - appid: "spreed" configkey: "turn_servers" + type: array configvalue: >- {{ [ @@ -45,6 +47,7 @@ config: configvalue: "{{ NEXTCLOUD_HPB_INTERNAL_SECRET }}" - appid: "spreed" configkey: "recording_servers" + type: array configvalue: >- {{ { diff --git a/roles/web-app-nextcloud/meta/services.yml b/roles/web-app-nextcloud/meta/services.yml index 076815ae89..36066ef180 100644 --- a/roles/web-app-nextcloud/meta/services.yml +++ b/roles/web-app-nextcloud/meta/services.yml @@ -66,6 +66,7 @@ nextcloud: enabled: true name: nextcloud image: nextcloud + # nocheck: docker-version integration_openproject v3.2.0 caps at Nextcloud 34; only its v3.3.0 nightlies declare 35, lift once 3.3.0 is stable version: 34-fpm-alpine custom: true min_storage: 553MB @@ -121,7 +122,7 @@ proxy: # nocheck: healthcheck-timing-override a sidecar of a stack whose main service alone takes minutes; the value predates this default and has not been measured against it start_period: 20m image: nginx - version: 1.31.5-alpine + version: 1.31.7-alpine backup: no_stop_required: true cpus: "0.5" @@ -140,7 +141,7 @@ context_agent: shared: "{{ lookup('config', 'web-app-nextcloud', 'mcp.enabled') | bool }}" name: nextcloud-context-agent image: ghcr.io/nextcloud/context_agent - version: 2.8.0 + version: 2.9.1 backup: no_stop_required: true # nocheck: single-replica diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/03_configure.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/03_configure.yml index 49155a8e4b..46c5f74ec0 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/03_configure.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/03_configure.yml @@ -10,7 +10,7 @@ set -euo pipefail {% for item in (addon_spec.config.plugin_configuration | default([])) %} {{ NEXTCLOUD_DOCKER_EXEC_OCC }} \ - config:app:set {{ item.appid }} {{ item.configkey }} \ + config:app:set {{ item.appid }} {{ item.configkey }}{{ (' --no-interaction --type=' ~ item.type) if item.type is defined else '' }} \ --value '{{ ( (item.configvalue | to_json) if (item.configvalue is mapping or (item.configvalue is sequence and item.configvalue is not string)) else (item.configvalue | string) ) | regex_replace("'", "'" ~ '"' ~ "'" ~ '"' ~ "'") }}' {% endfor %} diff --git a/tests/unit/python/utils/cache/test_applications.py b/tests/unit/python/utils/cache/test_applications.py index 0836ebb682..a54384868c 100644 --- a/tests/unit/python/utils/cache/test_applications.py +++ b/tests/unit/python/utils/cache/test_applications.py @@ -16,6 +16,7 @@ import tempfile import textwrap import unittest from pathlib import Path +from unittest import mock from utils.cache import _reset_cache_for_tests from utils.cache import applications as cache_apps @@ -422,6 +423,60 @@ class TestGetMergedApplicationsCarrierFact(unittest.TestCase): ) +class TestNestedLookupsReuseTheUnrenderedView(unittest.TestCase): + def setUp(self) -> None: + _reset_cache_for_tests() + + def test_nested_calls_during_one_render_build_the_defaults_once(self): + with tempfile.TemporaryDirectory() as tmp: + roles = _seed_minimal_roles(Path(tmp)) + _write( + roles / "web-app-foo" / ROLE_FILE_META_SERVICES, + """ + foo: + image: "pre-{{ nested }}" + version: "v-{{ nested }}" + """, + ) + variables = { + "applications": { + "web-app-foo": {"volumes": {"data": {"type": "volume"}}} + } + } + nested_views = [] + + class _NestingTemplar: + def __init__(self) -> None: + self.available_variables: dict = {} + + def template(self, value, **_kwargs): + nested_views.append( + cache_apps.get_merged_applications( + variables=self.available_variables, + roles_dir=roles, + templar=self, + ) + ) + return "rendered" + + with mock.patch.object( + cache_apps, + "get_application_defaults", + wraps=cache_apps.get_application_defaults, + ) as defaults: + rendered = cache_apps.get_merged_applications( + variables=variables, roles_dir=roles, templar=_NestingTemplar() + ) + + self.assertEqual(defaults.call_count, 1) + self.assertGreaterEqual(len(nested_views), 2) + self.assertTrue(all(view is nested_views[0] for view in nested_views)) + self.assertIn("volumes", nested_views[0]["web-app-foo"]) + self.assertEqual( + rendered["web-app-foo"]["services"]["foo"]["image"], "rendered" + ) + + class TestRecursiveMetaWalk(unittest.TestCase): """`_build_role_base_config` walks the whole `meta/` tree: every `meta/**/*.yml` lands at the matching nested path under diff --git a/utils/cache/README.md b/utils/cache/README.md index cfe7657783..a13fa7c6a4 100644 --- a/utils/cache/README.md +++ b/utils/cache/README.md @@ -28,6 +28,8 @@ Both layers are process-wide in-process caches. They intentionally do NOT track Inside a play the process boundary is the task: Ansible forks a fresh worker per task, so `_MERGED_APPLICATIONS_CACHE` starts empty in every worker. `get_merged_applications` therefore also accepts the play-scoped carrier fact `_INFINITO_APPLICATIONS_RENDERED` (payload plus cache key, parked by the constructor stage and again after every role, every nested application load and every persisted token) and serves it when the key matches. See [applications.md](../../docs/contributing/artefact/files/plugins/lookup/applications.md) for the contract. +While one render runs, the re-entry guard also holds that render's unrendered view and its cache key. A nested `get_merged_applications` call from a lookup inside a role template (`config`, `tls`, `domain`, ...) with the same key returns that view instead of copying and merging the role defaults again. The view is shared by every nested call of the render, so callers MUST NOT mutate it. The guard drops it when the render ends. + ## Test-Only Helpers 🧪 Each module owns a private `_reset()` that clears its own cache dicts. The package-level `utils.cache._reset_cache_for_tests()` orchestrates all six (`base`, `applications`, `users`, `domains`, `yaml`, `files`) plus the shared fingerprint memo. Unit tests that exercise the cached paths MUST call it in `setUp` to guarantee clean state across test cases. diff --git a/utils/cache/applications.py b/utils/cache/applications.py index 53bd526813..80e86b4534 100644 --- a/utils/cache/applications.py +++ b/utils/cache/applications.py @@ -419,6 +419,9 @@ def get_merged_applications( carried_nested = getattr(_RENDER_GUARD, "nested_payloads", {}).get(cache_key) if carried_nested is not None: return carried_nested + unrendered = getattr(_RENDER_GUARD, "unrendered_applications", None) + if unrendered is not None and unrendered[0] == cache_key: + return unrendered[1] defaults = get_application_defaults(roles_dir=resolved_roles_dir) @@ -433,6 +436,8 @@ def get_merged_applications( payloads[cache_key] = merged return merged + _RENDER_GUARD.unrendered_applications = (cache_key, copy.deepcopy(merged)) + for app_cfg in merged.values(): if isinstance(app_cfg, dict): app_cfg.pop("volumes", None) @@ -454,6 +459,7 @@ def get_merged_applications( finally: _RENDER_GUARD.applications = False _RENDER_GUARD.nested_payloads = {} + _RENDER_GUARD.unrendered_applications = None for app_id in rendered: raw_volumes = get_canonical_volumes(app_id)