diff --git a/roles/web-app-nextcloud/filter_plugins/nextcloud_plugin_plan.py b/roles/web-app-nextcloud/filter_plugins/nextcloud_plugin_plan.py new file mode 100644 index 0000000000..125cdb1985 --- /dev/null +++ b/roles/web-app-nextcloud/filter_plugins/nextcloud_plugin_plan.py @@ -0,0 +1,67 @@ +from __future__ import annotations + + +def _flag(value) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in ("true", "yes", "1", "on") + + +def _setting(plugin: dict, key: str, empty): + config = plugin.get("config") or {} + if key in config: + return config[key] + return plugin.get(key, empty) + + +def nextcloud_plugin_plan(plugin_items, app_list): + """Split the addon entries by the work each one still needs. + + Args: + plugin_items: ``dict2items`` of the role's ``addons`` config, in order; + an enabled entry turns its ``incompatible_plugins`` off and itself on, + a disabled entry turns itself off, and a later entry wins. + app_list: parsed ``occ app:list --output=json``. + + Returns: + dict of app ids: + disable - wanted off and enabled right now + install - wanted on and not installed, or shipped as a pinned archive + enable - wanted on, installed, not enabled + configure - wanted on and already enabled + """ + enabled_now = set((app_list or {}).get("enabled") or {}) + installed = enabled_now | set((app_list or {}).get("disabled") or {}) + + wanted: dict[str, bool] = {} + for item in plugin_items: + plugin = item["value"] + if _flag(plugin.get("enabled", False)): + for incompatible in _setting(plugin, "incompatible_plugins", []) or []: + wanted[incompatible] = False + wanted[item["key"]] = True + else: + wanted[item["key"]] = False + + plan = { + "disable": [app for app, on in wanted.items() if not on and app in enabled_now], + "install": [], + "enable": [], + "configure": [], + } + for item in plugin_items: + key = item["key"] + if not wanted.get(key): + continue + if key not in installed or _setting(item["value"], "archive", ""): + plan["install"].append(key) + elif key not in enabled_now: + plan["enable"].append(key) + else: + plan["configure"].append(key) + return plan + + +class FilterModule: + def filters(self): + return {"nextcloud_plugin_plan": nextcloud_plugin_plan} diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops.yml b/roles/web-app-nextcloud/tasks/02_manager_ops.yml index 1499d2f534..cdb26b0cae 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops.yml @@ -51,14 +51,6 @@ file: 02_manager_ops/06_plugin.yml apply: delegate_to: "{{ NEXTCLOUD_HOST_NODE }}" - loop: "{{ NEXTCLOUD_PLUGIN_ITEMS }}" - loop_control: - loop_var: plugin_item - label: "{{ plugin_item.key }}" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - vars: - plugin_key: "{{ plugin_item.key }}" - plugin_value: "{{ plugin_item.value }}" when: NEXTCLOUD_PLUGINS_ENABLED | bool - name: 🔧 Reconcile administrator identities after plugin setup diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml index c8d294d83a..ef38c302f8 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml @@ -1,12 +1,51 @@ --- -- block: - - include_tasks: 06_plugin/01_install.yml - when: plugin_value.enabled | bool - -- name: 🔴 disable {{ plugin_key }} nextcloud plugin - shell: "{{ NEXTCLOUD_DOCKER_EXEC_OCC }} app:disable {{ plugin_key }}" +- name: "📋 Read the apps Nextcloud has installed and enabled" + ansible.builtin.command: + cmd: "{{ NEXTCLOUD_DOCKER_EXEC_OCC }} app:list --output=json" + register: nextcloud_app_list changed_when: false - when: not (plugin_value.enabled | bool) - # rationale: async; caller includes this file per plugin so the disables overlap, nothing reads the state - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + +- name: "🧮 Plan which plugins need an install, an enable or only their config" + ansible.builtin.set_fact: + nextcloud_plugin_plan: "{{ NEXTCLOUD_PLUGIN_ITEMS | nextcloud_plugin_plan(nextcloud_app_list.stdout | from_json) }}" + +- name: "🔴 Disable the plugins that end up disabled" + ansible.builtin.command: + cmd: "{{ NEXTCLOUD_DOCKER_EXEC_OCC }} app:disable {{ nextcloud_plugin_plan.disable | map('quote') | join(' ') }}" + when: nextcloud_plugin_plan.disable | length > 0 + changed_when: true + +- name: "📦 Install, enable and configure the plugins Nextcloud lacks" + ansible.builtin.include_tasks: 06_plugin/01_install.yml + loop: "{{ NEXTCLOUD_PLUGIN_ITEMS | selectattr('key', 'in', nextcloud_plugin_plan.install) | list }}" + loop_control: + loop_var: plugin_item + label: "{{ plugin_item.key }}" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + vars: + plugin_key: "{{ plugin_item.key }}" + plugin_value: "{{ plugin_item.value }}" + +- name: "🟢 Enable and configure the installed plugins Nextcloud keeps disabled" + ansible.builtin.include_tasks: 06_plugin/02_enable.yml + loop: "{{ NEXTCLOUD_PLUGIN_ITEMS | selectattr('key', 'in', nextcloud_plugin_plan.enable) | list }}" + loop_control: + loop_var: plugin_item + label: "{{ plugin_item.key }}" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + vars: + plugin_key: "{{ plugin_item.key }}" + plugin_value: "{{ plugin_item.value }}" + plugin_task_path: "{{ NEXTCLOUD_CNODE_PLUGIN_TASKS_PATH }}{{ plugin_item.key }}.yml" + +- name: "🔧 Configure the plugins Nextcloud already runs" + ansible.builtin.include_tasks: 06_plugin/03_configure.yml + loop: "{{ NEXTCLOUD_PLUGIN_ITEMS | selectattr('key', 'in', nextcloud_plugin_plan.configure) | list }}" + loop_control: + loop_var: plugin_item + label: "{{ plugin_item.key }}" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + vars: + plugin_key: "{{ plugin_item.key }}" + plugin_value: "{{ plugin_item.value }}" + plugin_task_path: "{{ NEXTCLOUD_CNODE_PLUGIN_TASKS_PATH }}{{ plugin_item.key }}.yml" diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/README.md b/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/README.md index 11290d5143..fe5d9a1bae 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/README.md +++ b/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/README.md @@ -1,6 +1,6 @@ # Nextcloud plugin pipeline -This directory holds the per-plugin install + enable + configure pipeline that runs once for every addon declared under `meta/addons/`. +This directory holds the per-plugin install + enable + configure pipeline for the addons declared under `meta/addons/`. [06_plugin.yml](../06_plugin.yml) reads `occ app:list` once, plans the work with the filter `nextcloud_plugin_plan` ([filter_plugins/nextcloud_plugin_plan.py](../../../filter_plugins/nextcloud_plugin_plan.py)) and enters each plugin at the first stage it still needs. ## Scope @@ -10,7 +10,16 @@ The pipeline owns three responsibilities for one Nextcloud plugin (app): 2. Enable the plugin so the in-app code paths are active. 3. Apply plugin-specific configuration keys and run any role-local hook. -When a plugin is marked `enabled: false`, `06_plugin.yml` runs `occ app:disable` for that plugin instead of entering the pipeline. +## Plan + +| Plan list | Plugins | Entered at | +|---|---|---| +| `disable` | wanted off and enabled right now | one `occ app:disable` for all of them | +| `install` | wanted on and not installed, or pinned through `archive` | [01_install.yml](./01_install.yml) | +| `enable` | wanted on, installed, not enabled | [02_enable.yml](./02_enable.yml) | +| `configure` | wanted on and already enabled | [03_configure.yml](./03_configure.yml) | + +The wanted state replays the addons in order: an enabled plugin turns its `incompatible_plugins` off and itself on, a plugin marked `enabled: false` turns itself off, and a later entry wins. ## Pipeline stages @@ -62,10 +71,10 @@ Missing files MUST be tolerated; the corresponding step skips cleanly. ## Mutually exclusive plugins An addon under `meta/addons/` MAY declare an `incompatible_plugins` list in its `config:`. -[01_install.yml](./01_install.yml) runs `occ app:disable` for every listed plugin before the install attempt. +The plan's `disable` list carries every listed plugin that ends up off and still runs, and [01_install.yml](./01_install.yml) disables the listed plugins again before an install attempt. This guarantees that mutually exclusive integrations such as the OIDC entry points (`oidc_login`, `sociallogin`, `user_oidc`) and the document-editing surfaces (`onlyoffice`, `richdocuments`, `fileslibreofficeedit`) cannot accidentally coexist on the same instance. ## Entry point -The pipeline is invoked from [06_plugin.yml](../06_plugin.yml), which in turn runs once per iteration of the `NEXTCLOUD_PLUGIN_ITEMS` loop in [02_manager_ops.yml](../../02_manager_ops.yml). -The loop variable `plugin_item` is unpacked into `plugin_key` (the OCC app name) and `plugin_value` (its meta entry). +[02_manager_ops.yml](../../02_manager_ops.yml) includes [06_plugin.yml](../06_plugin.yml) once. +It loops over the `NEXTCLOUD_PLUGIN_ITEMS` of each plan list and unpacks the loop variable `plugin_item` into `plugin_key` (the OCC app name) and `plugin_value` (its meta entry). diff --git a/tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_plugin_plan.py b/tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_plugin_plan.py new file mode 100644 index 0000000000..959441e468 --- /dev/null +++ b/tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_plugin_plan.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest + +from . import PROJECT_ROOT + + +def _load_module(rel_path: str, name: str): + path = PROJECT_ROOT / rel_path + spec = importlib.util.spec_from_file_location(name, str(path)) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +plan_mod = _load_module( + "roles/web-app-nextcloud/filter_plugins/nextcloud_plugin_plan.py", + "nextcloud_plugin_plan", +) +nextcloud_plugin_plan = plan_mod.nextcloud_plugin_plan + + +def _items(*entries): + return [{"key": key, "value": value} for key, value in entries] + + +def _apps(enabled=(), disabled=()): + return { + "enabled": dict.fromkeys(enabled, "1.0.0"), + "disabled": dict.fromkeys(disabled, "1.0.0"), + } + + +class TestNextcloudPluginPlan(unittest.TestCase): + def test_enabled_and_running_plugins_only_get_configured(self): + plan = nextcloud_plugin_plan( + _items(("calendar", {"enabled": True}), ("deck", {"enabled": True})), + _apps(enabled=("calendar", "deck")), + ) + self.assertEqual( + plan, + { + "disable": [], + "install": [], + "enable": [], + "configure": ["calendar", "deck"], + }, + ) + + def test_missing_plugin_is_installed(self): + plan = nextcloud_plugin_plan(_items(("deck", {"enabled": True})), _apps()) + self.assertEqual(plan["install"], ["deck"]) + self.assertEqual(plan["configure"], []) + + def test_installed_but_disabled_plugin_is_enabled(self): + plan = nextcloud_plugin_plan( + _items(("recognize", {"enabled": True})), _apps(disabled=("recognize",)) + ) + self.assertEqual(plan["enable"], ["recognize"]) + self.assertEqual(plan["install"], []) + + def test_pinned_archive_always_goes_through_install(self): + plan = nextcloud_plugin_plan( + _items( + ( + "onlyoffice", + {"enabled": True, "config": {"archive": "https://x/o.tar.gz"}}, + ) + ), + _apps(enabled=("onlyoffice",)), + ) + self.assertEqual(plan["install"], ["onlyoffice"]) + self.assertEqual(plan["configure"], []) + + def test_disabled_plugin_is_disabled_only_while_it_runs(self): + running = nextcloud_plugin_plan( + _items(("news", {"enabled": False})), _apps(enabled=("news",)) + ) + idle = nextcloud_plugin_plan( + _items(("news", {"enabled": False})), _apps(disabled=("news",)) + ) + self.assertEqual(running["disable"], ["news"]) + self.assertEqual(idle["disable"], []) + + def test_later_entry_wins_like_the_per_plugin_loop(self): + items = _items( + ("fileslibreofficeedit", {"enabled": True}), + ( + "onlyoffice", + { + "enabled": True, + "config": { + "incompatible_plugins": [ + "richdocuments", + "fileslibreofficeedit", + ] + }, + }, + ), + ("richdocuments", {"enabled": True}), + ) + plan = nextcloud_plugin_plan( + items, + _apps(enabled=("fileslibreofficeedit", "onlyoffice", "richdocuments")), + ) + self.assertEqual(plan["disable"], ["fileslibreofficeedit"]) + self.assertEqual(plan["configure"], ["onlyoffice", "richdocuments"]) + self.assertEqual(plan["install"], []) + + def test_incompatible_plugin_outside_the_addons_is_disabled(self): + plan = nextcloud_plugin_plan( + _items( + ( + "oidc_login", + { + "enabled": True, + "config": { + "incompatible_plugins": ["user_oidc", "sociallogin"] + }, + }, + ), + ), + _apps(enabled=("oidc_login", "user_oidc")), + ) + self.assertEqual(plan["disable"], ["user_oidc"]) + self.assertEqual(plan["configure"], ["oidc_login"]) + + def test_string_flags_from_templated_config(self): + plan = nextcloud_plugin_plan( + _items(("deck", {"enabled": "True"}), ("news", {"enabled": "False"})), + _apps(enabled=("deck", "news")), + ) + self.assertEqual(plan["configure"], ["deck"]) + self.assertEqual(plan["disable"], ["news"]) + + def test_empty_app_list_installs_every_wanted_plugin(self): + plan = nextcloud_plugin_plan(_items(("deck", {"enabled": True})), {}) + self.assertEqual(plan["install"], ["deck"]) + + +if __name__ == "__main__": + unittest.main()