From f6fffd13e1d0cef596dd59f657a6effe2d1ef543 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 22 Oct 2026 09:52:36 +0200 Subject: [PATCH] feat(web-app-nextcloud): step existing data through every major up to the pin Nextcloud refuses to skip a major. Before the pinned image starts, the role reads $OC_Version from version.php in the code volume, plans one tag per major between that version and the pin, and runs the image of each step once against the data (occ upgrade through the entrypoint, with the debug log lines filtered out of the output). Then the regular manager ops run on the pinned image and assert that occ status reports the pinned major. A fresh volume plans no step; swarm stops when data needs one. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/web-app-nextcloud/README.md | 22 ++ .../filter_plugins/nextcloud_upgrade.py | 141 ++++++++++++ .../tasks/02_manager_ops.yml | 3 + .../tasks/02_manager_ops/03_upgrade.yml | 19 ++ .../tasks/utils/major_upgrade.yml | 69 ++++++ .../tasks/utils/major_upgrade/compose.yml | 41 ++++ .../tasks/utils/major_upgrade/step.yml | 62 +++++ .../templates/major_upgrade.yml.j2 | 5 + roles/web-app-nextcloud/vars/main.yml | 21 ++ .../filter_plugins/test_nextcloud_upgrade.py | 214 ++++++++++++++++++ 10 files changed, 597 insertions(+) create mode 100644 roles/web-app-nextcloud/filter_plugins/nextcloud_upgrade.py create mode 100644 roles/web-app-nextcloud/tasks/utils/major_upgrade.yml create mode 100644 roles/web-app-nextcloud/tasks/utils/major_upgrade/compose.yml create mode 100644 roles/web-app-nextcloud/tasks/utils/major_upgrade/step.yml create mode 100644 roles/web-app-nextcloud/templates/major_upgrade.yml.j2 create mode 100644 tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_upgrade.py diff --git a/roles/web-app-nextcloud/README.md b/roles/web-app-nextcloud/README.md index 453c6bad2f..b396448fe6 100644 --- a/roles/web-app-nextcloud/README.md +++ b/roles/web-app-nextcloud/README.md @@ -193,6 +193,28 @@ The enable-only appstore apps stay under `nextcloud.plugins` in [`meta/services. The SSO (`sociallogin`) and LDAP (`user_ldap`) login surfaces are covered by the OIDC/LDAP Playwright specs (requirements 017/018). +## Major Upgrades + +Every deploy reads `$OC_Version` from `version.php` in the `nextcloud_data` volume and compares its major with the pinned `services.nextcloud.version`, which MUST start with the major number (`34-fpm-alpine`, `32.0.3-fpm-alpine`). + +| Data | Behaviour | +|------|-----------| +| No volume or no `version.php` | Fresh install through the image entrypoint | +| Pinned major | Regular deploy, minor updates run in the entrypoint and `occ upgrade` | +| Older major | One upgrade step per major up to the pinned one, then the regular deploy | +| Newer major | The deploy stops, Nextcloud cannot downgrade | + +[`tasks/utils/major_upgrade.yml`](./tasks/utils/major_upgrade.yml) runs the steps before the compose handlers start the pinned image: + +1. It stops every running container that mounts `nextcloud_data`. +2. Per major it renders [`templates/major_upgrade.yml.j2`](./templates/major_upgrade.yml.j2) as `compose.upgrade.yml`, which swaps the image of the `nextcloud` service for the official `nextcloud:` image. The suffix comes from the pinned tag, the last step uses the pinned tag itself. +3. `compose run` starts that image with the service's env file, volumes, networks and dependencies and `NEXTCLOUD_UPDATE=1`, so the upstream entrypoint syncs the code and runs `occ upgrade`. +4. Maintenance mode is switched off, `occ status` MUST report the step's major with no pending database upgrade, and every app `occ upgrade` disabled that is still disabled after the step is reported. + +After the compose handlers ran, [`tasks/02_manager_ops/03_upgrade.yml`](./tasks/02_manager_ops/03_upgrade.yml) asserts that `occ status` reports the pinned major. + +The steps run in compose mode only. A swarm deploy that finds an older major stops with the planned steps; pin one major above the installed one per deploy there. + ## MCP Server Nextcloud serves a Model Context Protocol endpoint through the AppAPI proxy of the `context_agent` ExApp. diff --git a/roles/web-app-nextcloud/filter_plugins/nextcloud_upgrade.py b/roles/web-app-nextcloud/filter_plugins/nextcloud_upgrade.py new file mode 100644 index 0000000000..36c98ef783 --- /dev/null +++ b/roles/web-app-nextcloud/filter_plugins/nextcloud_upgrade.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +import re + +from ansible.errors import AnsibleFilterError + +_OC_VERSION = re.compile( + r"\$OC_Version\s*=\s*(?:array\s*\(|\[)(?P[^)\]]*)(?:\)|\])\s*;" +) +_TAG = re.compile(r"^(?P\d+)(?:\.\d+)*(?P.*)$") +_INCOMPATIBLE = re.compile(r"Disabled incompatible app: (?P[A-Za-z0-9_.-]+)") +_STATUS_KEYS = ("installed", "versionstring", "maintenance", "needsDbUpgrade") + + +def nextcloud_version_php(content): + """Return the dotted ``$OC_Version`` a Nextcloud ``version.php`` declares. + + Args: + content: text of ``version.php`` from the code volume. + + Returns: + The version as ``major.minor.patch.build``, e.g. ``31.0.11.1``. + """ + match = _OC_VERSION.search(str(content)) + if match is None: + raise AnsibleFilterError("nextcloud_version_php: no $OC_Version in version.php") + parts = [part.strip() for part in match.group("parts").split(",")] + if not all(part.isdigit() for part in parts): + raise AnsibleFilterError( + f"nextcloud_version_php: $OC_Version is not numeric: {match.group(0)}" + ) + return ".".join(parts) + + +def nextcloud_tag_major(tag): + """Return the major version a Nextcloud image tag or version string starts with. + + Args: + tag: image tag such as ``34-fpm-alpine`` or version such as ``34.0.5.1``. + + Returns: + The major version as an integer. + """ + match = _TAG.match(str(tag).strip()) + if match is None: + raise AnsibleFilterError( + f"nextcloud_tag_major: '{tag}' does not start with a major version" + ) + return int(match.group("major")) + + +def nextcloud_major_steps(installed_version, target_tag): + """Return the image tags that bring installed data up to the pinned major. + + Args: + installed_version: version ``version.php`` declares, ``""`` when nothing + is installed. + target_tag: pinned image tag, e.g. ``34-fpm-alpine``. + + Returns: + One tag per major above the installed one, the pinned tag last; empty + when nothing is installed or the installed major is the pinned one. + """ + if not str(installed_version).strip(): + return [] + installed_major = nextcloud_tag_major(installed_version) + target_major = nextcloud_tag_major(target_tag) + match = _TAG.match(str(target_tag).strip()) + if installed_major > target_major: + raise AnsibleFilterError( + f"Nextcloud data was written by {installed_version}, newer than the " + f"pinned image tag {target_tag}; Nextcloud cannot downgrade" + ) + if installed_major == target_major: + return [] + intermediate = [ + f"{major}{match.group('suffix')}" + for major in range(installed_major + 1, target_major) + ] + return [*intermediate, str(target_tag).strip()] + + +def _json_object(output, keys, name): + for raw in reversed(str(output).splitlines()): + line = raw.strip() + if not line.startswith("{"): + continue + try: + parsed = json.loads(line) + except ValueError: + continue + if isinstance(parsed, dict) and all(key in parsed for key in keys): + return parsed + raise AnsibleFilterError(f"{name}: no JSON object in output: {str(output)[-500:]}") + + +def nextcloud_disabled_apps(output, app_list): + """Return the apps an upgrade step left disabled, sorted. + + ``occ upgrade`` disables every incompatible app and enables it again once + the app store delivered a compatible release, so a ``Disabled incompatible + app`` line only counts while the app is still missing from the enabled + apps after the step. The entrypoint's own "apps have been disabled" list + also names every app that was only updated and is not used. + + Args: + output: stdout of the image entrypoint run with ``NEXTCLOUD_UPDATE=1``. + app_list: stdout of ``occ app:list --output=json`` after the step. + + Returns: + The app ids ``occ upgrade`` disabled that are not enabled afterwards. + """ + enabled = _json_object(app_list, ("enabled",), "nextcloud_disabled_apps")["enabled"] + disabled = {match.group("app") for match in _INCOMPATIBLE.finditer(str(output))} + return sorted(disabled - set(enabled)) + + +def nextcloud_status(output): + """Return the object ``occ status --output=json`` printed. + + Args: + output: stdout of ``occ status --output=json``; PHP notices before the + JSON line are skipped. + + Returns: + The status dict with at least ``installed``, ``versionstring``, + ``maintenance`` and ``needsDbUpgrade``. + """ + return _json_object(output, _STATUS_KEYS, "nextcloud_status") + + +class FilterModule: + def filters(self): + return { + "nextcloud_version_php": nextcloud_version_php, + "nextcloud_tag_major": nextcloud_tag_major, + "nextcloud_major_steps": nextcloud_major_steps, + "nextcloud_disabled_apps": nextcloud_disabled_apps, + "nextcloud_status": nextcloud_status, + } diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops.yml b/roles/web-app-nextcloud/tasks/02_manager_ops.yml index e6cc801ff2..1499d2f534 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops.yml @@ -1,4 +1,7 @@ --- +- name: โซ Bring existing Nextcloud data up to the pinned major + include_tasks: utils/major_upgrade.yml + - name: ๐Ÿšฟ Flush handlers so Nextcloud container is restarted and ready meta: flush_handlers diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/03_upgrade.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/03_upgrade.yml index c4960649e0..7b06db60e5 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/03_upgrade.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops/03_upgrade.yml @@ -69,3 +69,22 @@ retries: 3 delay: 5 until: occ_maint_off.rc == 0 + +- name: ๐Ÿ” Nextcloud | Read occ status + shell: "{{ NEXTCLOUD_DOCKER_EXEC_OCC }} status --output=json" + register: occ_status + changed_when: false + +- name: โœ… Nextcloud | Verify occ status reports the pinned major + vars: + occ_status_state: "{{ occ_status.stdout | nextcloud_status }}" + assert: + that: + - (occ_status_state.versionstring | nextcloud_tag_major) == (NEXTCLOUD_VERSION_MAJOR | int) + - not (occ_status_state.needsDbUpgrade | bool) + fail_msg: >- + occ status reports {{ occ_status_state }}, the pinned image tag is {{ NEXTCLOUD_VERSION }}. + success_msg: >- + occ status reports Nextcloud {{ occ_status_state.versionstring }}, the pinned major + {{ NEXTCLOUD_VERSION_MAJOR }}. + quiet: false diff --git a/roles/web-app-nextcloud/tasks/utils/major_upgrade.yml b/roles/web-app-nextcloud/tasks/utils/major_upgrade.yml new file mode 100644 index 0000000000..9ccc952e86 --- /dev/null +++ b/roles/web-app-nextcloud/tasks/utils/major_upgrade.yml @@ -0,0 +1,69 @@ +--- +- name: ๐Ÿ” Check for an existing Nextcloud data volume + ansible.builtin.command: + argv: + - container + - volume + - inspect + - "{{ NEXTCLOUD_DATA_VOLUME }}" + register: nextcloud_data_volume_inspect + changed_when: false + failed_when: >- + nextcloud_data_volume_inspect.rc != 0 + and 'no such volume' not in (nextcloud_data_volume_inspect.stderr | lower) + +- name: ๐Ÿ“– Read version.php from the Nextcloud code volume + ansible.builtin.command: + argv: + - container + - run + - --rm + - --entrypoint + - cat + - --volume + - "{{ NEXTCLOUD_DATA_VOLUME }}:{{ NEXTCLOUD_DOCKER_WORK_DIRECTORY }}:ro" + - "{{ NEXTCLOUD_IMAGE }}:{{ NEXTCLOUD_VERSION }}" + - "{{ NEXTCLOUD_VERSION_PHP }}" + register: nextcloud_version_php_read + changed_when: false + failed_when: >- + nextcloud_version_php_read.rc != 0 + and 'No such file' not in nextcloud_version_php_read.stderr + timeout: "{{ lookup('timeout', 1800) | int }}" + when: nextcloud_data_volume_inspect.rc == 0 + +- name: ๐Ÿงฎ Record the Nextcloud version the existing data was written by + ansible.builtin.set_fact: + nextcloud_installed_version: >- + {{ + (nextcloud_version_php_read.stdout | nextcloud_version_php) + if (nextcloud_data_volume_inspect.rc == 0 and nextcloud_version_php_read.rc == 0) + else '' + }} + nextcloud_upgrade_disabled_apps: [] + +- name: ๐Ÿงฎ Plan one upgrade step per Nextcloud major up to the pinned one + ansible.builtin.set_fact: + nextcloud_upgrade_tags: "{{ nextcloud_installed_version | nextcloud_major_steps(NEXTCLOUD_VERSION) }}" + +- name: ๐Ÿงญ Report the Nextcloud major upgrade plan + ansible.builtin.debug: + msg: >- + Nextcloud data written by: {{ nextcloud_installed_version if nextcloud_installed_version else 'no installation' }}; + pinned image tag: {{ NEXTCLOUD_VERSION }}; + upgrade steps: {{ nextcloud_upgrade_tags | join(', ') if nextcloud_upgrade_tags else 'none' }} + +- name: ๐Ÿ›‘ Stop when swarm data needs a Nextcloud major upgrade + ansible.builtin.fail: + msg: >- + The Nextcloud data was written by {{ nextcloud_installed_version }} and the pinned + image tag {{ NEXTCLOUD_VERSION }} needs the steps {{ nextcloud_upgrade_tags | join(', ') }}. + The step runs use compose; deploy this host in compose mode for the upgrade or pin + services.nextcloud.version to one major above the installed one per deploy. + when: + - nextcloud_upgrade_tags | length > 0 + - DEPLOYMENT_MODE == 'swarm' + +- name: โซ Upgrade the Nextcloud data one major at a time + ansible.builtin.include_tasks: "{{ role_path }}/tasks/utils/major_upgrade/compose.yml" + when: DEPLOYMENT_MODE == 'compose' and nextcloud_upgrade_tags | length > 0 diff --git a/roles/web-app-nextcloud/tasks/utils/major_upgrade/compose.yml b/roles/web-app-nextcloud/tasks/utils/major_upgrade/compose.yml new file mode 100644 index 0000000000..9c93ee1590 --- /dev/null +++ b/roles/web-app-nextcloud/tasks/utils/major_upgrade/compose.yml @@ -0,0 +1,41 @@ +--- +# include-gated: when: DEPLOYMENT_MODE == "compose" +- name: "๐ŸŒ Pre-create the external networks the upgrade runs join" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/network/create.yml') }}" + vars: + network_role_id: "{{ external_network_role }}" + loop: "{{ lookup('compose_external_networks') }}" + loop_control: + loop_var: external_network_role + +- name: ๐Ÿ” List the running containers that mount the Nextcloud data volume + ansible.builtin.command: + argv: + - container + - ps + - --quiet + - --filter + - "volume={{ NEXTCLOUD_DATA_VOLUME }}" + register: nextcloud_volume_containers + changed_when: false + +- name: โน๏ธ Stop the containers that still run the old Nextcloud code + ansible.builtin.command: + argv: "{{ ['container', 'stop'] + nextcloud_volume_containers.stdout_lines }}" + changed_when: true + when: nextcloud_volume_containers.stdout_lines | length > 0 + +- name: โซ Run one Nextcloud upgrade step per major + ansible.builtin.include_tasks: "{{ role_path }}/tasks/utils/major_upgrade/step.yml" + loop: "{{ nextcloud_upgrade_tags }}" + loop_control: + loop_var: nextcloud_upgrade_tag + +- name: ๐Ÿงน Remove the Nextcloud upgrade compose override + ansible.builtin.file: + path: "{{ NEXTCLOUD_UPGRADE_COMPOSE_HOST }}" + state: absent + +- name: ๐Ÿงฉ Report the apps the Nextcloud major upgrades disabled + ansible.builtin.debug: + msg: "{{ nextcloud_upgrade_disabled_apps }}" diff --git a/roles/web-app-nextcloud/tasks/utils/major_upgrade/step.yml b/roles/web-app-nextcloud/tasks/utils/major_upgrade/step.yml new file mode 100644 index 0000000000..4dce4ef01a --- /dev/null +++ b/roles/web-app-nextcloud/tasks/utils/major_upgrade/step.yml @@ -0,0 +1,62 @@ +--- +# include-gated: when: DEPLOYMENT_MODE == "compose" +- name: "๐Ÿ“„ Point the Nextcloud upgrade run at {{ nextcloud_upgrade_tag }}" + stack_host_template: + src: major_upgrade.yml.j2 + dest: "{{ NEXTCLOUD_UPGRADE_COMPOSE_HOST }}" + +- name: "โซ Nextcloud upgrade run with {{ NEXTCLOUD_IMAGE }}:{{ nextcloud_upgrade_tag }}" + ansible.builtin.shell: | + set -euo pipefail + {{ (NEXTCLOUD_UPGRADE_RUN + ['--env', 'NEXTCLOUD_UPDATE=1', '--entrypoint', '/entrypoint.sh', NEXTCLOUD_SERVICE, 'true']) | map('quote') | join(' ') }} 2>&1 \ + | sed -E '/^\[nextcloud\]\[[^]]*\]\[[01]\] /d' + args: + executable: /bin/bash + register: nextcloud_upgrade_step + changed_when: true + timeout: "{{ lookup('timeout', 3600) | int }}" + +- name: "๐Ÿ”ด Leave maintenance mode after the {{ nextcloud_upgrade_tag }} step" + ansible.builtin.command: + argv: "{{ NEXTCLOUD_UPGRADE_OCC + ['maintenance:mode', '--off'] }}" + register: nextcloud_upgrade_maintenance + changed_when: "'already disabled' not in nextcloud_upgrade_maintenance.stdout" + +- name: "๐Ÿ” Read occ status after the {{ nextcloud_upgrade_tag }} step" + ansible.builtin.command: + argv: "{{ NEXTCLOUD_UPGRADE_OCC + ['status', '--output=json'] }}" + register: nextcloud_upgrade_status + changed_when: false + +- name: "โœ… Verify occ status reports the Nextcloud {{ nextcloud_upgrade_tag }} major" + vars: + nextcloud_upgrade_state: "{{ nextcloud_upgrade_status.stdout | nextcloud_status }}" + ansible.builtin.assert: + that: + - nextcloud_upgrade_state.installed | bool + - not (nextcloud_upgrade_state.maintenance | bool) + - not (nextcloud_upgrade_state.needsDbUpgrade | bool) + - (nextcloud_upgrade_state.versionstring | nextcloud_tag_major) == (nextcloud_upgrade_tag | nextcloud_tag_major) + fail_msg: >- + After the {{ nextcloud_upgrade_tag }} step occ status reports {{ nextcloud_upgrade_state }}. + success_msg: >- + occ status reports Nextcloud {{ nextcloud_upgrade_state.versionstring }} after the + {{ nextcloud_upgrade_tag }} step, maintenance off, no pending database upgrade. + quiet: false + +- name: "๐Ÿ” List the enabled apps after the {{ nextcloud_upgrade_tag }} step" + ansible.builtin.command: + argv: "{{ NEXTCLOUD_UPGRADE_OCC + ['app:list', '--output=json'] }}" + register: nextcloud_upgrade_apps + changed_when: false + +- name: "๐Ÿงฉ Record the apps the Nextcloud {{ nextcloud_upgrade_tag }} upgrade disabled" + ansible.builtin.set_fact: + nextcloud_upgrade_disabled_apps: >- + {{ nextcloud_upgrade_disabled_apps + + [{'step': nextcloud_upgrade_tag, + 'disabled': nextcloud_upgrade_step.stdout | nextcloud_disabled_apps(nextcloud_upgrade_apps.stdout)}] }} + +- name: "๐Ÿงฉ Apps the Nextcloud {{ nextcloud_upgrade_tag }} upgrade disabled" + ansible.builtin.debug: + msg: "{{ nextcloud_upgrade_step.stdout | nextcloud_disabled_apps(nextcloud_upgrade_apps.stdout) }}" diff --git a/roles/web-app-nextcloud/templates/major_upgrade.yml.j2 b/roles/web-app-nextcloud/templates/major_upgrade.yml.j2 new file mode 100644 index 0000000000..7ff2f1828b --- /dev/null +++ b/roles/web-app-nextcloud/templates/major_upgrade.yml.j2 @@ -0,0 +1,5 @@ +services: + {{ NEXTCLOUD_SERVICE }}: + {{ lookup('container_image', application_id, NEXTCLOUD_SERVICE, image=NEXTCLOUD_IMAGE, version=nextcloud_upgrade_tag) }} + {{ lookup('compose_only', 'pull_policy', 'missing') }} + build: !reset null diff --git a/roles/web-app-nextcloud/vars/main.yml b/roles/web-app-nextcloud/vars/main.yml index f0cdb2c8c5..3abcb22786 100644 --- a/roles/web-app-nextcloud/vars/main.yml +++ b/roles/web-app-nextcloud/vars/main.yml @@ -46,6 +46,27 @@ NEXTCLOUD_INIT_GATE_HOST: "{{ [lookup('container', 'web-app-nextcloud', 'directo NEXTCLOUD_SERVICE: "{{ entity_name }}" NEXTCLOUD_VERSION: "{{ lookup('config', application_id, 'services.'~ NEXTCLOUD_SERVICE ~'.version') }}" +NEXTCLOUD_VERSION_MAJOR: "{{ NEXTCLOUD_VERSION | nextcloud_tag_major }}" +NEXTCLOUD_DATA_VOLUME: "{{ lookup('volume', application_id, 'data').name }}" +NEXTCLOUD_VERSION_PHP: "{{ [NEXTCLOUD_DOCKER_WORK_DIRECTORY, 'version.php'] | path_join }}" +NEXTCLOUD_UPGRADE_COMPOSE_FILE: "compose.upgrade.yml" +NEXTCLOUD_UPGRADE_COMPOSE_HOST: "{{ [lookup('container', application_id, 'directories.instance'), NEXTCLOUD_UPGRADE_COMPOSE_FILE] | path_join }}" +NEXTCLOUD_UPGRADE_RUN: + - "{{ BIN_COMPOSE }}" + - --chdir + - "{{ lookup('container', application_id, 'directories.instance') }}" + - --project + - "{{ (lookup('container', application_id, 'directories.instance') | regex_replace('/+$', '')) | basename }}" + - --file + - "{{ NEXTCLOUD_UPGRADE_COMPOSE_FILE }}" + - -- + - run + - --rm + - -T +NEXTCLOUD_UPGRADE_OCC: >- + {{ NEXTCLOUD_UPGRADE_RUN + + ['--no-deps', '--user', NEXTCLOUD_DOCKER_USER, '--entrypoint', 'php', + NEXTCLOUD_SERVICE, NEXTCLOUD_INTERNAL_OCC_COMMAND] }} NEXTCLOUD_IMAGE: "{{ lookup('config', application_id, 'services.'~ NEXTCLOUD_SERVICE ~'.image') }}" NEXTCLOUD_CONTAINER: "{{ lookup('config', application_id, 'services.'~ NEXTCLOUD_SERVICE ~'.name') }}" NEXTCLOUD_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, NEXTCLOUD_SERVICE) }}" diff --git a/tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_upgrade.py b/tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_upgrade.py new file mode 100644 index 0000000000..ced0016ffe --- /dev/null +++ b/tests/unit/python/roles/web-app-nextcloud/filter_plugins/test_nextcloud_upgrade.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest + +from ansible.errors import AnsibleFilterError + +from . import PROJECT_ROOT + +VERSION_PHP_31 = """ + array ( + '30.0' => true, + '31.0' => true, + ), +); +$OC_Build = '2025-11-13T12:00:00+00:00 0123456789abcdef'; +$vendor = 'nextcloud'; +""" + +ENTRYPOINT_UPGRADE_OUTPUT = """Initializing nextcloud 32.0.1.2 ... +Upgrading nextcloud from 31.0.11.1 ... +Setting log level to debugging +Updating database schema +Updated database +Disabled incompatible app: whiteboard +Update app deck from App Store +Starting code integrity check... +Finished code integrity check +Update successful +Maintenance mode is kept active +Resetting log level +The following apps have been disabled: + deck + whiteboard +Initializing finished +""" + +APP_LIST_JSON = ( + '{"enabled":{"activity":"5.0.0","deck":"1.16.2","files":"2.4.0"},' + '"disabled":{"whiteboard":"1.0.5"}}' +) + +STATUS_JSON = ( + '{"installed":true,"version":"32.0.1.2","versionstring":"32.0.1",' + '"edition":"","maintenance":false,"needsDbUpgrade":false,' + '"productname":"Nextcloud","extendedSupport":false}' +) + + +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 + + +MODULE = _load_module( + "roles/web-app-nextcloud/filter_plugins/nextcloud_upgrade.py", "nextcloud_upgrade" +) + + +class TestNextcloudVersionPhp(unittest.TestCase): + def test_array_form_yields_the_dotted_version(self): + self.assertEqual(MODULE.nextcloud_version_php(VERSION_PHP_31), "31.0.11.1") + + def test_short_array_form_yields_the_dotted_version(self): + content = "