perf(web-app-nextcloud): plan the plugin pipeline from one app:list

Every addon ran through the install, enable and configure stages on its own, and each stage asked occ for the plugin's state again. The role now reads occ app:list --output=json once, and the new nextcloud_plugin_plan filter replays the addon order (an enabled entry switches its incompatible_plugins off and itself on, a disabled entry switches itself off, the later entry wins) and splits the app ids into disable, install, enable and configure. One app:disable call takes every id that has to go off; the install, enable and configure includes loop only over the ids that need that stage, so an already enabled plugin skips straight to its configuration. A plugin shipped as a pinned archive still goes through install on every run.

On echoserver the plugin phase took 608 s in full-9 for 51 addons; a repeat deploy is derived at about 195 s instead of about 550 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-25 07:39:16 +01:00
1 parent d670d4f537
commit 3044ae7a2e
5 files changed
+276 -23

No files matched your search

@@ -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}
@@ -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
@@ -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"
@@ -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).
@@ -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()