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 = "