diff --git a/compose/swarm/Dockerfile b/compose/swarm/Dockerfile index 5e70fa0d79..492e19289b 100644 --- a/compose/swarm/Dockerfile +++ b/compose/swarm/Dockerfile @@ -1,7 +1,8 @@ # Node image for the swarm DR test cluster, layered on the distro's infinito # image so python + ansible + the CLI (venv) are already baked -- the per-node # .deb bootstrap is gone. Adds only the node extras: dnsmasq (lab DNS), openssh -# server (backup pull chain), cryptsetup + e2fsprogs (LUKS drill device). +# server (backup pull chain), cryptsetup + e2fsprogs (LUKS drill device), kmod +# (modprobe, for the data-root filesystem). # Distro-agnostic install (apt/dnf/pacman) since INFINITO_IMAGE is per-distro. # systemd-resolved is masked so it never races dnsmasq for port 53. The pacman # branch refreshes the keyring and does a full -Syu: the pinned parent image can @@ -22,20 +23,28 @@ FROM ${INFINITO_IMAGE} RUN set -eux; \ if command -v apt-get >/dev/null 2>&1; then \ apt-get update -qq; \ - apt-get install -y -qq --no-install-recommends dnsmasq openssh-server cryptsetup e2fsprogs elfutils; \ + apt-get install -y -qq --no-install-recommends dnsmasq openssh-server cryptsetup e2fsprogs elfutils kmod; \ + . /etc/os-release; \ + if [ "${ID:-}" = debian ] && [ -n "${VERSION_CODENAME:-}" ]; then \ + echo "deb http://deb.debian.org/debian ${VERSION_CODENAME} contrib" >/etc/apt/sources.list.d/contrib.list; \ + apt-get update -qq || rm -f /etc/apt/sources.list.d/contrib.list; \ + fi; \ + apt-get install -y -qq --no-install-recommends zfsutils-linux || \ + echo "zfsutils-linux unavailable; a zfs data-root pick will decline on this image" >&2; \ rm -rf /var/lib/apt/lists/*; \ elif command -v dnf >/dev/null 2>&1; then \ - dnf install -y dnsmasq openssh-server cryptsetup e2fsprogs elfutils; \ + dnf install -y dnsmasq openssh-server cryptsetup e2fsprogs elfutils kmod; \ dnf clean all; \ elif command -v pacman >/dev/null 2>&1; then \ pacman -Sy --noconfirm --needed archlinux-keyring; \ - pacman -Syu --noconfirm --needed dnsmasq openssh cryptsetup e2fsprogs elfutils; \ + pacman -Syu --noconfirm --needed dnsmasq openssh cryptsetup e2fsprogs elfutils kmod; \ rm -rf /var/cache/pacman/pkg/*; \ else \ echo "no supported package manager (apt/dnf/pacman)" >&2; exit 1; \ fi; \ systemctl mask systemd-resolved; \ systemctl mask systemd-udevd systemd-udevd-kernel.socket systemd-udevd-control.socket systemd-udevd-varlink.socket systemd-udev-trigger; \ + systemctl mask modprobe@.service; \ systemctl enable ssh 2>/dev/null || systemctl enable sshd; \ chmod u+rw /etc/shadow; \ if [ -e /etc/gshadow ]; then chmod u+rw /etc/gshadow; fi; \ diff --git a/plugins/filter/async_failures.py b/plugins/filter/async_failures.py index 0b58619844..1f1828ad42 100644 --- a/plugins/filter/async_failures.py +++ b/plugins/filter/async_failures.py @@ -39,15 +39,18 @@ def _which(result: dict) -> str: """Name the job, so a batch failure says which item broke. A collected result nests the fired result under ``item``, which in turn - nests the original loop value - a model name, a user entry - under its own - ``item``. Without it a twenty-user batch reports only how many failed. + nests the original loop value - a model name, a user entry - under the key + ansible used for it. That key is ``item`` unless ``loop_control.loop_var`` + renamed it, in which case the fired result records the name it chose under + ``ansible_loop_var``. Without it a twenty-user batch reports only how many + failed. Args: result: one collected ``async_status`` result. """ fired = result.get("item") if isinstance(fired, dict): - original = fired.get("item") + original = fired.get(fired.get("ansible_loop_var") or "item") if original not in (None, ""): if isinstance(original, dict): for key in ("key", "name", "model"): diff --git a/plugins/lookup/compose_external_networks.py b/plugins/lookup/compose_external_networks.py index fc4302113c..cbf57f14d0 100644 --- a/plugins/lookup/compose_external_networks.py +++ b/plugins/lookup/compose_external_networks.py @@ -13,25 +13,13 @@ even when the provider role did not run in the same play. from __future__ import annotations -import contextlib from typing import Any from ansible.errors import AnsibleError -from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase +from utils.networks.lookup_context import build_context, resolve_var from utils.networks.render import compute_external_network_roles -from utils.roles.applications.services.registry import ( - build_service_registry_from_applications, -) - - -def _resolve_var(templar, value: Any) -> Any: - if templar is None: - return value - with contextlib.suppress(Exception): - return templar.template(value) - return value class LookupModule(LookupBase): @@ -46,42 +34,20 @@ class LookupModule(LookupBase): "compose_external_networks lookup takes no positional terms" ) - vars_ = variables or getattr(self._templar, "available_variables", {}) or {} - templar = getattr(self, "_templar", None) + ctx = build_context(self, variables, honour_mode_force=False) - application_id = _resolve_var(templar, vars_.get("application_id")) + application_id = resolve_var(ctx.templar, ctx.vars.get("application_id")) if not application_id: raise AnsibleError( "compose_external_networks lookup: application_id is required in variables" ) - deployment_mode = str( - _resolve_var(templar, vars_.get("DEPLOYMENT_MODE", "compose")) - ) - - applications = lookup_loader.get( - "applications", loader=self._loader, templar=getattr(self, "_templar", None) - ).run([], variables=vars_)[0] - registry = build_service_registry_from_applications(applications) - - config_lookup = lookup_loader.get( - "config", loader=self._loader, templar=templar - ) - database_lookup = lookup_loader.get( - "database", loader=self._loader, templar=templar - ) - - def _lookup_config(app: str, path: str, default: Any) -> Any: - return config_lookup.run([app, path, default], variables=vars_)[0] - - def _lookup_database(app: str, key: str) -> Any: - return database_lookup.run([app, key], variables=vars_)[0] - - roles = compute_external_network_roles( - application_id=str(application_id), - deployment_mode=deployment_mode, - registry=registry, - lookup_config=_lookup_config, - lookup_database=_lookup_database, - ) - return [roles] + return [ + compute_external_network_roles( + application_id=str(application_id), + deployment_mode=ctx.deployment_mode, + registry=ctx.registry, + lookup_config=ctx.lookup_config, + lookup_database=ctx.lookup_database, + ) + ] diff --git a/plugins/lookup/compose_networks.py b/plugins/lookup/compose_networks.py index 31f285da14..8a67620e33 100644 --- a/plugins/lookup/compose_networks.py +++ b/plugins/lookup/compose_networks.py @@ -11,28 +11,16 @@ The output starts at column 0 (no caller-side indent). from __future__ import annotations -import contextlib from typing import Any from ansible.errors import AnsibleError -from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase +from utils.networks.lookup_context import build_context, resolve_var from utils.networks.render import render_compose_networks -from utils.roles.applications.services.registry import ( - build_service_registry_from_applications, -) from utils.roles.entity.name import get_entity_name -def _resolve_var(templar, value: Any) -> Any: - if templar is None: - return value - with contextlib.suppress(Exception): - return templar.template(value) - return value - - class LookupModule(LookupBase): def run( self, @@ -43,52 +31,29 @@ class LookupModule(LookupBase): if terms: raise AnsibleError("compose_networks lookup takes no positional terms") - vars_ = variables or getattr(self._templar, "available_variables", {}) or {} - templar = getattr(self, "_templar", None) + ctx = build_context(self, variables) - application_id = _resolve_var(templar, vars_.get("application_id")) + application_id = resolve_var(ctx.templar, ctx.vars.get("application_id")) if not application_id: raise AnsibleError( "compose_networks lookup: application_id is required in variables" ) - mode_force = _resolve_var(templar, vars_.get("compose_mode_force", "")) - deployment_mode = str( - mode_force or _resolve_var(templar, vars_.get("DEPLOYMENT_MODE", "compose")) - ).strip() - - swarm_cfg = _resolve_var(templar, vars_.get("swarm", {})) or {} - swarm_encrypted = True + swarm_cfg = resolve_var(ctx.templar, ctx.vars.get("swarm", {})) or {} net_cfg = swarm_cfg.get("network", {}) if isinstance(swarm_cfg, dict) else {} + swarm_encrypted = True if isinstance(net_cfg, dict) and "encryption" in net_cfg: swarm_encrypted = bool(net_cfg.get("encryption")) - applications = lookup_loader.get( - "applications", loader=self._loader, templar=getattr(self, "_templar", None) - ).run([], variables=vars_)[0] - registry = build_service_registry_from_applications(applications) - - config_lookup = lookup_loader.get( - "config", loader=self._loader, templar=templar - ) - database_lookup = lookup_loader.get( - "database", loader=self._loader, templar=templar - ) - - def _lookup_config(app: str, path: str, default: Any) -> Any: - return config_lookup.run([app, path, default], variables=vars_)[0] - - def _lookup_database(app: str, key: str) -> Any: - return database_lookup.run([app, key], variables=vars_)[0] - - rendered = render_compose_networks( - application_id=str(application_id), - deployment_mode=deployment_mode, - registry=registry, - get_entity_name=get_entity_name, - lookup_config=_lookup_config, - lookup_database=_lookup_database, - swarm_encrypted=swarm_encrypted, - node_local=bool(kwargs.get("node_local", False)), - ) - return [rendered] + return [ + render_compose_networks( + application_id=str(application_id), + deployment_mode=ctx.deployment_mode, + registry=ctx.registry, + get_entity_name=get_entity_name, + lookup_config=ctx.lookup_config, + lookup_database=ctx.lookup_database, + swarm_encrypted=swarm_encrypted, + node_local=bool(kwargs.get("node_local", False)), + ) + ] diff --git a/plugins/lookup/container_healthcheck.py b/plugins/lookup/container_healthcheck.py index 6f2808a7d2..9d5b1571f8 100644 --- a/plugins/lookup/container_healthcheck.py +++ b/plugins/lookup/container_healthcheck.py @@ -55,8 +55,8 @@ class LookupModule(LookupBase): self._vars = ( variables or getattr(self._templar, "available_variables", {}) or {} ) - self._application_id = kwargs.get("application_id") or self._vars.get( - "application_id" + self._application_id = self._render( + kwargs.get("application_id") or self._vars.get("application_id") ) if self._templar is not None and "{{" in str(self._application_id or ""): with contextlib.suppress(Exception): @@ -102,7 +102,9 @@ class LookupModule(LookupBase): config: that service's healthcheck entry. kwargs: the call site's keyword arguments. """ - hostname = config.get("hostname", self._vars.get("container_hostname")) + hostname = self._render( + config.get("hostname", self._vars.get("container_hostname")) + ) port_key = str(config.get("port_key", "http") or "http") context = { "test": config.get("test"), @@ -120,6 +122,29 @@ class LookupModule(LookupBase): ) return context + def _render(self, value: Any) -> Any: + """Template a value read straight out of the play vars. + + Args: + value: raw var value; ``include_role: vars:`` hands these over + unrendered, e.g. ``application_id: "{{ sys_stk_full_application_id }}"``. + + Raises: + AnsibleError: the value stayed a template. ansible-core only + renders strings tagged trusted and returns the rest verbatim, + which would bake ``{{ ... }}`` into the compose file. + """ + templar = getattr(self, "_templar", None) + if templar is None or not isinstance(value, str) or "{{" not in value: + return value + rendered = templar.template(value) + if isinstance(rendered, str) and "{{" in rendered: + raise AnsibleError( + f"container_healthcheck: '{value}' did not render; it reached the " + "lookup untrusted as a template." + ) + return rendered + def _config(self, path: str, default: Any) -> Any: """Read one services.yml value through the config SPOT, templated. diff --git a/plugins/lookup/container_networks.py b/plugins/lookup/container_networks.py index 95e064fe06..b6cf120a6f 100644 --- a/plugins/lookup/container_networks.py +++ b/plugins/lookup/container_networks.py @@ -1,39 +1,27 @@ -"""Lookup ``container_networks``: emit the per-service ``networks:`` -attachment block. Reads the service_registry plus ``application_id`` and +"""Lookup ``container_networks``: emit the per-service ``networks:`` attachment +block. Reads the service_registry, the rendering role's ``application_id`` and ``DEPLOYMENT_MODE`` from variables. -Usage inside a service block in ``compose.yml.j2``: +Usage in any service template: - {{ lookup('container_networks') }} + {{ lookup('container_networks') | indent(4) }} -The output is pre-indented by 4 spaces so it sits cleanly under a -``services.`` block. +Pass ``provider_self_alias=False`` for a sidecar service that must not carry the +provider's own alias. """ from __future__ import annotations -import contextlib from typing import Any from ansible.errors import AnsibleError -from ansible.plugins.loader import lookup_loader from ansible.plugins.lookup import LookupBase +from utils.networks.lookup_context import build_context, resolve_var from utils.networks.render import render_container_networks -from utils.roles.applications.services.registry import ( - build_service_registry_from_applications, -) from utils.roles.entity.name import get_entity_name -def _resolve_var(templar, value: Any) -> Any: - if templar is None: - return value - with contextlib.suppress(Exception): - return templar.template(value) - return value - - class LookupModule(LookupBase): def run( self, @@ -44,47 +32,23 @@ class LookupModule(LookupBase): if terms: raise AnsibleError("container_networks lookup takes no positional terms") - vars_ = variables or getattr(self._templar, "available_variables", {}) or {} - templar = getattr(self, "_templar", None) + ctx = build_context(self, variables) - application_id = _resolve_var(templar, vars_.get("application_id")) + application_id = resolve_var(ctx.templar, ctx.vars.get("application_id")) if not application_id: raise AnsibleError( "container_networks lookup: application_id is required in variables" ) - mode_force = _resolve_var(templar, vars_.get("compose_mode_force", "")) - deployment_mode = str( - mode_force or _resolve_var(templar, vars_.get("DEPLOYMENT_MODE", "compose")) - ).strip() - provider_self_alias = bool(kwargs.get("provider_self_alias", True)) - - applications = lookup_loader.get( - "applications", loader=self._loader, templar=getattr(self, "_templar", None) - ).run([], variables=vars_)[0] - registry = build_service_registry_from_applications(applications) - - config_lookup = lookup_loader.get( - "config", loader=self._loader, templar=templar - ) - database_lookup = lookup_loader.get( - "database", loader=self._loader, templar=templar - ) - - def _lookup_config(app: str, path: str, default: Any) -> Any: - return config_lookup.run([app, path, default], variables=vars_)[0] - - def _lookup_database(app: str, key: str) -> Any: - return database_lookup.run([app, key], variables=vars_)[0] - - rendered = render_container_networks( - application_id=str(application_id), - deployment_mode=deployment_mode, - registry=registry, - get_entity_name=get_entity_name, - lookup_config=_lookup_config, - lookup_database=_lookup_database, - provider_self_alias=provider_self_alias, - node_local=bool(kwargs.get("node_local", False)), - ) - return [rendered] + return [ + render_container_networks( + application_id=str(application_id), + deployment_mode=ctx.deployment_mode, + registry=ctx.registry, + get_entity_name=get_entity_name, + lookup_config=ctx.lookup_config, + lookup_database=ctx.lookup_database, + provider_self_alias=bool(kwargs.get("provider_self_alias", True)), + node_local=bool(kwargs.get("node_local", False)), + ) + ] diff --git a/plugins/lookup/shared_network_key.py b/plugins/lookup/shared_network_key.py new file mode 100644 index 0000000000..e4d98602aa --- /dev/null +++ b/plugins/lookup/shared_network_key.py @@ -0,0 +1,60 @@ +"""Lookup `shared_network_key`: the compose networks key of the pre-created +```` network of a role. + +Docker compose stores that key in the ``com.docker.compose.network`` label of +every network it owns, so a caller pre-creating the network out of band has to +stamp the same value or compose will resolve the project's networks against a +claim the rendered file does not make. The key is derived from +``utils.networks.render``, which renders the file itself, so the two cannot +drift. + +Examples: + + - name: create the shared network + ansible.builtin.command: + argv: + - container + - network + - create + - --label + - "com.docker.compose.network={{ lookup('shared_network_key', role_id) }}" +""" + +from __future__ import annotations + +from typing import Any + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase + +from utils.networks.lookup_context import build_context, resolve_var +from utils.networks.render import shared_network_compose_key +from utils.roles.entity.name import get_entity_name + + +class LookupModule(LookupBase): + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[str]: + if not terms or len(terms) != 1: + raise AnsibleError( + "shared_network_key lookup requires exactly one term: application_id" + ) + + ctx = build_context(self, variables) + application_id = str(resolve_var(ctx.templar, terms[0])) + + return [ + shared_network_compose_key( + application_id=application_id, + deployment_mode=ctx.deployment_mode, + registry=ctx.registry, + get_entity_name=get_entity_name, + lookup_config=ctx.lookup_config, + lookup_database=ctx.lookup_database, + node_local=bool(kwargs.get("node_local", False)), + ) + ] diff --git a/roles/svc-ai-ollama/tasks/01_manager_ops.yml b/roles/svc-ai-ollama/tasks/01_manager_ops.yml index 4a0789e70f..4ca4c4bbed 100644 --- a/roles/svc-ai-ollama/tasks/01_manager_ops.yml +++ b/roles/svc-ai-ollama/tasks/01_manager_ops.yml @@ -17,6 +17,9 @@ async: "{{ OLLAMA_PRELOAD_ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" changed_when: false + failed_when: >- + (pull_result.failed | default(false)) + and 'up to date' not in ((pull_result.stdout | default('')) ~ (pull_result.stderr | default(''))) delegate_to: "{{ OLLAMA_HOST_NODE }}" - name: "⏳ Reap the Ollama model pulls" diff --git a/roles/svc-db-mariadb/tasks/00_core.yml b/roles/svc-db-mariadb/tasks/00_core.yml index fda61d50ba..a986f0e93b 100644 --- a/roles/svc-db-mariadb/tasks/00_core.yml +++ b/roles/svc-db-mariadb/tasks/00_core.yml @@ -49,6 +49,7 @@ include_tasks: 02_reset_root_password.yml when: - mariadb_login_desired.rc != 0 + - "'No such container' not in (mariadb_login_desired.stderr | default(''))" when: MARIADB_ALLOW_RESET_WITHOUT_PREVIOUS | bool diff --git a/roles/svc-prx-openresty/handlers/restart_and_validate.yml b/roles/svc-prx-openresty/handlers/restart_and_validate.yml index cc93fa7565..a101164285 100644 --- a/roles/svc-prx-openresty/handlers/restart_and_validate.yml +++ b/roles/svc-prx-openresty/handlers/restart_and_validate.yml @@ -62,6 +62,7 @@ changed_when: false failed_when: false ignore_errors: true + delegate_to: "{{ STACK_HOST }}" - ansible.builtin.debug: msg: | diff --git a/roles/sys-ctl-rpr-container-hard/tasks/00_core.yml b/roles/sys-ctl-rpr-container-hard/tasks/00_core.yml index 959b8f9a09..76f8ccc145 100644 --- a/roles/sys-ctl-rpr-container-hard/tasks/00_core.yml +++ b/roles/sys-ctl-rpr-container-hard/tasks/00_core.yml @@ -7,6 +7,7 @@ - include_role: name: sys-service vars: + system_service_suppress_flush: true system_service_on_calendar: "{{ SYS_SCHEDULE_REPAIR_DOCKER_HARD }}" system_service_timer_enabled: true system_service_tpl_exec_start_pre: '{{ BIN_LOCK }} {{ SYS_SERVICE_GROUP_MANIPULATION | join(" ") }} --ignore {{ SYS_SERVICE_REPAIR_CONTAINER_HARD }} {{ SYS_SERVICE_GROUP_CLEANUP diff --git a/roles/sys-svc-compose/tasks/utils/network/create.yml b/roles/sys-svc-compose/tasks/utils/network/create.yml index 185feb05dd..75d80f9235 100644 --- a/roles/sys-svc-compose/tasks/utils/network/create.yml +++ b/roles/sys-svc-compose/tasks/utils/network/create.yml @@ -36,7 +36,7 @@ - --subnet - "{{ lookup('config', network_role_id, 'networks.local.subnet') }}" - --label - - com.docker.compose.network=default + - "com.docker.compose.network={{ lookup('shared_network_key', network_role_id) }}" - --label - "com.docker.compose.project={{ network_role_id | get_entity_name }}" - "{{ network_role_id | get_entity_name }}" @@ -73,7 +73,7 @@ - --subnet - "{{ lookup('config', network_role_id, 'networks.local.subnet') }}" - --label - - com.docker.compose.network=default + - "com.docker.compose.network={{ lookup('shared_network_key', network_role_id) }}" - --label - "com.docker.compose.project={{ network_role_id | get_entity_name }}" - "{{ network_role_id | get_entity_name }}" diff --git a/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.service b/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.service index e92eba3393..eba22b3e02 100644 --- a/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.service +++ b/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.service @@ -6,6 +6,7 @@ Requires=docker.service [Service] Type=oneshot Environment=PATH=/opt/ansible/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=PYTHONUNBUFFERED=1 ExecStartPre=/usr/bin/docker info ExecStart=/usr/local/bin/matrix-mdad-bootstrap RemainAfterExit=yes diff --git a/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.sh b/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.sh index 300a41fc83..766b476292 100644 --- a/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.sh +++ b/roles/web-app-matrix/files/flavor/ansible/runner/bootstrap.sh @@ -35,10 +35,18 @@ fi cd /mdad -START_GALAXY=$SECONDS -echo ">>> matrix-mdad-bootstrap: starting ansible-galaxy install" -/opt/ansible/bin/ansible-galaxy install -r requirements.yml -p roles/galaxy/ --force -echo "<<< matrix-mdad-bootstrap: galaxy install done in $((SECONDS - START_GALAXY))s" +GALAXY_STAMP=roles/galaxy/.requirements.sha256 +GALAXY_WANT=$(sha256sum requirements.yml | cut -d' ' -f1) +if [ -f "$GALAXY_STAMP" ] && [ "$(cat "$GALAXY_STAMP")" = "$GALAXY_WANT" ] && [ -d roles/galaxy ]; then + echo ">>> matrix-mdad-bootstrap: galaxy roles match requirements.yml ($GALAXY_WANT), skipping install" +else + START_GALAXY=$SECONDS + echo ">>> matrix-mdad-bootstrap: starting ansible-galaxy install" + rm -f "$GALAXY_STAMP" + /opt/ansible/bin/ansible-galaxy install -r requirements.yml -p roles/galaxy/ --force + printf '%s\n' "$GALAXY_WANT" > "$GALAXY_STAMP" + echo "<<< matrix-mdad-bootstrap: galaxy install done in $((SECONDS - START_GALAXY))s" +fi START_PLAY=$SECONDS echo ">>> matrix-mdad-bootstrap: starting ansible-playbook setup.yml --tags=${MATRIX_MDAD_PLAYBOOK_TAGS:-setup-all,start}" diff --git a/roles/web-app-matrix/tasks/flavor/ansible/05_proxy_wiring.yml b/roles/web-app-matrix/tasks/flavor/ansible/05_proxy_wiring.yml index b5f907d3e2..1b49fb68ff 100644 --- a/roles/web-app-matrix/tasks/flavor/ansible/05_proxy_wiring.yml +++ b/roles/web-app-matrix/tasks/flavor/ansible/05_proxy_wiring.yml @@ -65,7 +65,7 @@ {% if 'docker=failed' in matrix_mdad_bootstrap_state.stdout_lines | default([]) -%} the docker daemon inside matrix-mdad failed to start - see matrix_mdad_diagnostics above. {%- elif 'ActiveState=failed' in matrix_mdad_bootstrap_state.stdout_lines | default([]) -%} - matrix-mdad bootstrap playbook failed inside the container - see matrix_mdad_diagnostics above. + matrix-mdad bootstrap failed inside the container, in its galaxy install or its playbook - see matrix_mdad_diagnostics above. {%- elif matrix_mdad_bootstrap_state is failed -%} matrix-mdad bootstrap never reached a terminal state within the wait budget - see matrix_mdad_diagnostics above. {%- else -%} diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops.yml b/roles/web-app-nextcloud/tasks/02_manager_ops.yml index d164325100..a34ef1fcac 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops.yml @@ -37,15 +37,9 @@ apply: delegate_to: "{{ NEXTCLOUD_HOST_NODE }}" -- name: ⚙️ Apply the system configuration - include_tasks: - file: 02_manager_ops/05_system_config.yml - apply: - delegate_to: "{{ NEXTCLOUD_HOST_NODE }}" - - name: 🔧 Setup Nextcloud Plugins include_tasks: - file: 02_manager_ops/06_plugin.yml + file: 02_manager_ops/05_plugin.yml apply: delegate_to: "{{ NEXTCLOUD_HOST_NODE }}" loop: "{{ NEXTCLOUD_PLUGIN_ITEMS }}" @@ -58,7 +52,7 @@ - name: 🔧 Reconcile administrator identities after plugin setup include_tasks: - file: 02_manager_ops/07_admin_identities.yml + file: 02_manager_ops/06_admin_identities.yml apply: delegate_to: "{{ NEXTCLOUD_HOST_NODE }}" diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin.yml similarity index 91% rename from roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml rename to roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin.yml index c8d294d83a..9b7e1649f6 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin.yml +++ b/roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin.yml @@ -1,6 +1,6 @@ --- - block: - - include_tasks: 06_plugin/01_install.yml + - include_tasks: 05_plugin/01_install.yml when: plugin_value.enabled | bool - name: 🔴 disable {{ plugin_key }} nextcloud plugin diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/01_install.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/01_install.yml similarity index 100% rename from roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/01_install.yml rename to roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/01_install.yml diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/02_enable.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/02_enable.yml similarity index 100% rename from roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/02_enable.yml rename to roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/02_enable.yml 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/05_plugin/03_configure.yml similarity index 100% rename from roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/03_configure.yml rename to roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/03_configure.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/05_plugin/README.md similarity index 96% rename from roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/README.md rename to roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/README.md index 11290d5143..25abaf5415 100644 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/06_plugin/README.md +++ b/roles/web-app-nextcloud/tasks/02_manager_ops/05_plugin/README.md @@ -10,11 +10,11 @@ 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. +When a plugin is marked `enabled: false`, `05_plugin.yml` runs `occ app:disable` for that plugin instead of entering the pipeline. ## Pipeline stages -Each `tasks/02_manager_ops/06_plugin/.yml` file owns exactly one OCC stage and one decision about whether to continue. +Each `tasks/02_manager_ops/05_plugin/.yml` file owns exactly one OCC stage and one decision about whether to continue. | File | Stage | OCC command | Continues to next stage when | |---|---|---|---| @@ -67,5 +67,5 @@ This guarantees that mutually exclusive integrations such as the OIDC entry poin ## 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 pipeline is invoked from [05_plugin.yml](../05_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). diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/05_system_config.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/05_system_config.yml deleted file mode 100644 index 578d206dc3..0000000000 --- a/roles/web-app-nextcloud/tasks/02_manager_ops/05_system_config.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -- name: ⚙️ Load System Nextcloud configuration variables - include_vars: - file: system.yml - -- name: "📌 Apply Nextcloud system configs (batched shell)" - ansible.builtin.shell: | - set -euo pipefail - {% for item in nextcloud_system_config %} - {{ NEXTCLOUD_DOCKER_EXEC_OCC }} \ - config:system:set {{ item.parameter }}{% if item.type is defined %} --type {{ item.type }}{% endif %} \ - --value '{{ (item.value | string) | regex_replace("'", "'" ~ '"' ~ "'" ~ '"' ~ "'") }}' - {% endfor %} - args: - executable: /bin/bash - register: syscfg_shell - changed_when: > - ( ((syscfg_shell.stdout | default('')) ~ (syscfg_shell.stderr | default(''))) - is search(' set to ') ) - failed_when: syscfg_shell.rc != 0 diff --git a/roles/web-app-nextcloud/tasks/02_manager_ops/07_admin_identities.yml b/roles/web-app-nextcloud/tasks/02_manager_ops/06_admin_identities.yml similarity index 100% rename from roles/web-app-nextcloud/tasks/02_manager_ops/07_admin_identities.yml rename to roles/web-app-nextcloud/tasks/02_manager_ops/06_admin_identities.yml diff --git a/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml b/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml index fbd11d6650..87a97e39df 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_openproject_provision.yml @@ -1,11 +1,4 @@ --- -- name: ⏳ Wait for the OpenProject web endpoint - wait_for: - host: "{{ lookup('domain', 'web-app-openproject') }}" - port: 80 - delay: 5 - timeout: "{{ lookup('timeout', 600) | int }}" - - name: "🔍 Resolve the OpenProject web hosting node and container id" ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" vars: @@ -16,6 +9,13 @@ resolve_converge_retries: 120 resolve_converge_delay: 5 +- name: ⏳ Wait for the OpenProject web endpoint + wait_for: + host: "{{ lookup('domain', 'web-app-openproject') }}" + port: 80 + delay: 5 + timeout: "{{ lookup('timeout', 600) | int }}" + - name: 📁 Create the Nextcloud storage + OpenProject OAuth application ansible.builtin.shell: cmd: >- # nocheck: container-exec-resolver NEXTCLOUD_OPENPROJECT_LOCAL_CID resolved at delegate_to runtime; container_address would return a manager-only subshell diff --git a/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml b/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml index d6a25d478c..da66d43ec1 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml @@ -10,13 +10,6 @@ nextcloud_zammad_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.base') | regex_replace('/$', '') }}" changed_when: false -- name: 🔍 Probe whether the Zammad partner container is running - shell: > - container inspect --type container -f '{% raw %}{{.State.Running}}{% endraw %}' {{ NEXTCLOUD_ZAMMAD_CONTAINER_ADDRESS }} - register: nextcloud_zammad_partner_running - changed_when: false - failed_when: false - - name: 📖 Read persisted integration_zammad OAuth client id shell: > {{ NEXTCLOUD_DOCKER_EXEC_OCC }} config:app:get integration_zammad client_id @@ -36,7 +29,6 @@ include_tasks: integration_zammad_provision.yml when: >- IS_STACK_HOST | bool - and (nextcloud_zammad_partner_running.stdout | trim) == 'true' and ( (nextcloud_zammad_client_id_current.stdout | trim) | length == 0 or (nextcloud_zammad_client_secret_current.stdout | trim) | length == 0 diff --git a/roles/web-app-nextcloud/tasks/addons/integration_zammad_provision.yml b/roles/web-app-nextcloud/tasks/addons/integration_zammad_provision.yml index 18d685784a..9fdd4e4d63 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_zammad_provision.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_zammad_provision.yml @@ -1,4 +1,21 @@ --- +- name: "🔍 Resolve Zammad railsserver hosting node + container id" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" + vars: + resolve_service_key: railsserver + resolve_service_name: "{{ lookup('container_service', 'web-app-zammad', 'railsserver') }}" + resolve_output_prefix: NEXTCLOUD_ZAMMAD + resolve_compose_cid: "{{ NEXTCLOUD_ZAMMAD_CONTAINER_ADDRESS }}" + resolve_converge_retries: 120 + resolve_converge_delay: 5 + +- name: ⏳ Wait for the Zammad web endpoint + wait_for: + host: "{{ lookup('domain', 'web-app-zammad') }}" + port: 80 + delay: 5 + timeout: "{{ lookup('timeout', 600) | int }}" + - name: 🔧 Provision the Zammad OAuth application for Nextcloud and capture credentials ansible.builtin.shell: cmd: >- @@ -6,9 +23,10 @@ -e 'NC_BASE_URL={{ nextcloud_zammad_nc_base_url }}' -e 'NC_REDIRECT_PATH=/apps/integration_zammad/oauth-redirect' -e 'NC_HAS_SECRET={{ nextcloud_zammad_client_secret_current.stdout | trim }}' - {{ NEXTCLOUD_ZAMMAD_CONTAINER_ADDRESS }} + {{ NEXTCLOUD_ZAMMAD_LOCAL_CID | quote }} bundle exec rails r - stdin: "{{ lookup('file', 'zammad/provision_oauth_app.rb') }}" + delegate_to: "{{ NEXTCLOUD_ZAMMAD_HOST_NODE }}" register: nextcloud_zammad_provision retries: 12 delay: 10 diff --git a/roles/web-app-nextcloud/templates/config/README.md b/roles/web-app-nextcloud/templates/config/README.md index c3dc84bd8f..6ef0a9ff7f 100644 --- a/roles/web-app-nextcloud/templates/config/README.md +++ b/roles/web-app-nextcloud/templates/config/README.md @@ -1,4 +1,9 @@ # Nextcloud config templates -This folder contains configuration files which will be loaded direct into the config.php -If you don't use nested configuration, concider to use the vars/system.yml file instead, because it's a cleaner way to set the configuration. +Every `*.j2` here is rendered to the host-mounted config directory and merged +into `$CONFIG` by `zzz-infinito.config.php`, which Nextcloud applies after +`config.php`. Values set here therefore win. + +Put system configuration here rather than calling `occ config:system:set`: the +occ path takes an exclusive lock on `config.php`, which is a single NFS-shared +file behind every replica in swarm mode, while this path is read-only. diff --git a/roles/web-app-nextcloud/templates/config/system.config.php.j2 b/roles/web-app-nextcloud/templates/config/system.config.php.j2 new file mode 100644 index 0000000000..de93d81bac --- /dev/null +++ b/roles/web-app-nextcloud/templates/config/system.config.php.j2 @@ -0,0 +1,24 @@ + {{ 'false' if _legacy_login else 'true' }}, + 'auth.webauthn.enabled' => {{ 'true' if _legacy_login else 'false' }}, + 'maintenance_window_start' => {{ SYS_SCHEDULE_MAINTANANCE_NEXTCLOUD | int }}, + 'default_phone_region' => {{ php_str(HOST_LL | upper) }}, + 'trusted_domains' => [ + {{ php_str(NEXTCLOUD_DOMAIN) }}, + {{ php_str(NEXTCLOUD_PROXY_SERVICE) }}, + {{ php_str(NEXTCLOUD_PROXY_DNS_NAME) }}, + ], + 'overwrite.cli.url' => {{ php_str(NEXTCLOUD_URL) }}, + 'overwriteprotocol' => {{ php_str(NEXTCLOUD_PROTOCOL) }}, + 'overwritehost' => {{ php_str(NEXTCLOUD_DOMAIN) }}, + 'trusted_proxies' => [ +{%- for _proxy in NEXTCLOUD_TRUSTED_PROXIES %} + {{ php_str(_proxy) }}, +{%- endfor %} + ], +]; diff --git a/roles/web-app-nextcloud/vars/system.yml b/roles/web-app-nextcloud/vars/system.yml deleted file mode 100644 index bd015b2a6a..0000000000 --- a/roles/web-app-nextcloud/vars/system.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -nextcloud_system_config: - - parameter: "hide_login_form" - type: "boolean" - value: "{{ (not lookup('config', application_id, 'services.nextcloud.legacy_login_mask.enabled')) | lower }}" - - - parameter: "auth.webauthn.enabled" - type: "boolean" - value: "{{ lookup('config', application_id, 'services.nextcloud.legacy_login_mask.enabled') | lower }}" - - - parameter: "maintenance_window_start" - type: "integer" - value: "{{ SYS_SCHEDULE_MAINTANANCE_NEXTCLOUD }}" - - - parameter: "default_phone_region" - value: "{{ HOST_LL | upper }}" - - - parameter: "trusted_domains 0" - value: "{{ NEXTCLOUD_DOMAIN }}" - - - parameter: "trusted_domains 1" - value: "{{ NEXTCLOUD_PROXY_SERVICE }}" - - - parameter: "trusted_domains 2" - value: "{{ NEXTCLOUD_PROXY_DNS_NAME }}" - - - parameter: "overwrite.cli.url" - value: "{{ NEXTCLOUD_URL }}" - - - parameter: "overwriteprotocol" - value: "{{ NEXTCLOUD_PROTOCOL }}" - - - parameter: "overwritehost" - value: "{{ NEXTCLOUD_DOMAIN }}" - - - parameter: "trusted_proxies" - type: "json" - value: "{{ NEXTCLOUD_TRUSTED_PROXIES }}" diff --git a/scripts/tests/deploy/swarm/routine/backup/base.sh b/scripts/tests/deploy/swarm/routine/backup/base.sh index 898b0a4eab..9acfe68917 100755 --- a/scripts/tests/deploy/swarm/routine/backup/base.sh +++ b/scripts/tests/deploy/swarm/routine/backup/base.sh @@ -26,6 +26,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "${SCRIPT_DIR}/../../utils/_context.sh" : "${DRILL_EXTRAS:?DRILL_EXTRAS required (matrix passes the round extras)}" +: "${DISK_FLOOR_MB:?DISK_FLOOR_MB required (matrix passes its watchdog floor)}" DIR_VAR_LIB="${INFINITO_DIR_VAR_LIB:?INFINITO_DIR_VAR_LIB is not set - source scripts/meta/env/load.sh first}" DIR_BACKUPS="${INFINITO_DIR_BACKUPS:?INFINITO_DIR_BACKUPS is not set - regenerate .env via make dotenv}" @@ -141,20 +142,38 @@ USB_SIZE_MB=$((PULLED_MB * 2 + 256)) echo " ${PULLED_MB}M pulled; sizing the loop image to ${USB_SIZE_MB}M (2x pulled tree + headroom, floor 2G)" docker exec "${BACKUP_NODE}" bash "${BKP_IN_NODE}/02_luks_device.sh" \ "${USB_IMG}" "${DEV_MOUNT}" "${DEV_DEST}" "${USB_MAPPER}" "${USB_PASS}" "${USB_SIZE_MB}" +drill_device_teardown() { + docker exec "${BACKUP_NODE}" umount "${DEV_MOUNT}" 2>/dev/null || true + docker exec "${BACKUP_NODE}" cryptsetup luksClose "${USB_MAPPER}" 2>/dev/null || true + docker exec "${BACKUP_NODE}" rm -f "${USB_IMG}" 2>/dev/null || true +} +if ! DEVICE_FREE_RAW="$(docker exec "${BACKUP_NODE}" df --output=avail -B1M "${DEV_MOUNT}" 2>/dev/null)"; then + DEVICE_FREE_RAW="" +fi +DEVICE_FREE_MB="$(printf '%s\n' "${DEVICE_FREE_RAW}" | tail -n1 | tr -d ' ')" if ! FREE_RAW="$(docker exec "${BACKUP_NODE}" df --output=avail -B1M "${DIR_BACKUPS}" 2>/dev/null)"; then FREE_RAW="" fi FREE_MB="$(printf '%s\n' "${FREE_RAW}" | tail -n1 | tr -d ' ')" -case "${FREE_MB}" in -'' | *[!0-9]*) - echo "FAILURE: cannot read free space on ${BACKUP_NODE}:${DIR_BACKUPS} — df --output returned '${FREE_MB}'" +case "${DEVICE_FREE_MB}:${FREE_MB}" in +*[!0-9:]* | *::* | :* | *:) + echo "FAILURE: cannot read free space on ${BACKUP_NODE} — df --output returned device '${DEVICE_FREE_MB}', backup root '${FREE_MB}'" + drill_device_teardown exit 1 ;; esac -if [ "${FREE_MB}" -lt "${USB_SIZE_MB}" ]; then - echo "FAILURE: the drill holds the ${PULLED_MB}M pulled tree twice over (device sync, then the restore root plus one stage copy); ${FREE_MB}M free on ${BACKUP_NODE}:${DIR_BACKUPS}, ${USB_SIZE_MB}M needed" +if [ "${DEVICE_FREE_MB}" -lt "${PULLED_MB}" ]; then + echo "FAILURE: the encrypted device offers ${DEVICE_FREE_MB}M usable after mkfs on a ${USB_SIZE_MB}M image; the ${PULLED_MB}M pulled tree does not fit" + drill_device_teardown exit 1 fi +NEEDED_MB=$((PULLED_MB + DISK_FLOOR_MB)) +if [ "${FREE_MB}" -lt "${NEEDED_MB}" ]; then + echo "FAILURE: the sync holds the ${PULLED_MB}M pulled tree and its device copy at once (the restore root repeats that peak later), so ${NEEDED_MB}M must be free to stay above the ${DISK_FLOOR_MB}M watchdog floor; ${FREE_MB}M free on ${BACKUP_NODE}:${DIR_BACKUPS}" + drill_device_teardown + exit 1 +fi +echo " device offers ${DEVICE_FREE_MB}M, backup root has ${FREE_MB}M free, ${PULLED_MB}M to place" if ! docker exec "${BACKUP_NODE}" bash "${TRIGGER_UNITS}" 'svc-bkp-local-2-device*.service' "${UNIT_DUMPS}"; then echo "FAILURE: local-2-device unit missing or failed on ${BACKUP_NODE} (role not deployed?)" exit 1 diff --git a/scripts/tests/deploy/swarm/utils/trigger_units.sh b/scripts/tests/deploy/swarm/utils/trigger_units.sh index c695c9f899..b7baed8773 100755 --- a/scripts/tests/deploy/swarm/utils/trigger_units.sh +++ b/scripts/tests/deploy/swarm/utils/trigger_units.sh @@ -17,14 +17,16 @@ DUMPS="${2:?usage: trigger_units.sh PATTERN DUMPS}" units="$(systemctl list-unit-files "${PATTERN}" --no-legend | awk '{print $1}' || true)" [ -n "${units}" ] || exit 2 +NODE="$(uname -n)" + while read -r unit; do [ -n "${unit}" ] || continue - echo " starting ${unit} on $(hostname)" + echo " starting ${unit} on ${NODE}" if ! systemctl start "${unit}"; then mkdir -p "${DUMPS}" - dump="${DUMPS}/${unit}.$(hostname).journal.txt" + dump="${DUMPS}/${unit}.${NODE}.journal.txt" journalctl -u "${unit}" --no-pager -o short-iso >"${dump}" 2>/dev/null || true - echo "FAILURE: ${unit} did not complete on $(hostname); full journal at ${dump}" + echo "FAILURE: ${unit} did not complete on ${NODE}; full journal at ${dump}" journalctl -u "${unit}" --no-pager -o cat -n 40 2>/dev/null || true exit 1 fi diff --git a/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh b/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh index a65a9f26cc..6c11130733 100755 --- a/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh +++ b/scripts/tests/deploy/utils/filesystem/docker_dataroot.sh @@ -33,13 +33,16 @@ current_fstype() { echo unknown } +# Param: $1 status word recorded for this host +# Param: $2 reason behind the status, empty when it needs none verdict() { - local status="$1" effective host + local status="$1" reason effective host + reason="$(printf '%s' "${2:-}" | tr '\n' ' ')" effective="$(current_fstype)" host="$(hostname 2>/dev/null || echo unknown)" - report "status=${status} requested=${FSTYPE:-none} effective=${effective}" + report "status=${status} requested=${FSTYPE:-none} effective=${effective}${reason:+ reason=${reason}}" [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 - echo "- \`${host}\` docker data root: **${effective}** (requested \`${FSTYPE:-none}\`, ${status})" \ + echo "- \`${host}\` docker data root: **${effective}** (requested \`${FSTYPE:-none}\`, ${status})${reason:+ - ${reason}}" \ >>"${GITHUB_STEP_SUMMARY}" } @@ -63,11 +66,11 @@ arm_autoclear() { decline() { if [ "${REQUIRED}" = true ]; then report "FAIL: ${FSTYPE} was stated but cannot be delivered: $*" - verdict required-but-unavailable + verdict required-but-unavailable "$*" exit 1 fi report "skipping ${FSTYPE}: $*" - verdict declined + verdict declined "$*" exit 0 } @@ -129,12 +132,14 @@ btrfs) zfs) require_tool zpool zfsutils-linux zfs zfs-utils || decline "the zfs userland is unavailable on this distribution" - modprobe zfs 2>/dev/null || true + if ! modprobe_out="$(modprobe zfs 2>&1)"; then + report "modprobe zfs failed: ${modprobe_out}" + fi if [ ! -c /dev/zfs ]; then zfs_major="$(awk '$2 == "zfs" {print $1}' /proc/devices | head -n1)" [ -n "${zfs_major}" ] && mknod /dev/zfs c "${zfs_major}" 0 fi - [ -c /dev/zfs ] || decline "the zfs kernel module is not loaded on the host" + [ -c /dev/zfs ] || decline "the zfs kernel module is not loaded on the host: ${modprobe_out}" ;; esac @@ -147,9 +152,18 @@ if mountpoint -q "${MOUNT}"; then exit 0 fi +restore_docker() { + [ "${DOCKER_STOPPED:-false}" = true ] || return 0 + systemctl cat docker.service >/dev/null 2>&1 || return 0 + systemctl start docker || + report "WARNING: docker did not come back up after the ${FSTYPE} setup" +} + report "putting the docker data root on ${FSTYPE}" systemctl stop docker.socket 2>/dev/null || true systemctl stop docker 2>/dev/null || true +DOCKER_STOPPED=true +trap restore_docker EXIT mkdir -p "${MOUNT}" "$(dirname "${IMAGE}")" rm -f "${IMAGE}" @@ -172,7 +186,12 @@ done [ -n "${LOOP}" ] || decline "no loop device could be claimed after ${attempt} attempts" if [ "${FSTYPE}" = zfs ]; then - zpool create -f -m "${MOUNT}" -O acltype=posixacl -O xattr=sa "${POOL}" "${LOOP}" + if ! zpool_out="$(zpool create -f -m "${MOUNT}" -O acltype=posixacl -O xattr=sa "${POOL}" "${LOOP}" 2>&1)"; then + losetup -d "${LOOP}" || + report "WARNING: ${LOOP} refused the detach and will leak into the next round" + rm -f "${IMAGE}" + decline "zpool create failed: ${zpool_out}" + fi zfs create -o mountpoint="${MOUNT}/docker" "${POOL}/docker" else "mkfs.${FSTYPE}" -q "${LOOP}" @@ -199,6 +218,7 @@ PYTHON if systemctl cat docker.service >/dev/null 2>&1; then systemctl start docker + DOCKER_STOPPED=false verdict applied else report "docker.service is not installed yet; the data root takes effect when it is" diff --git a/scripts/tests/deploy/utils/filesystem/report.sh b/scripts/tests/deploy/utils/filesystem/report.sh index 195f28fe33..292bee42bd 100755 --- a/scripts/tests/deploy/utils/filesystem/report.sh +++ b/scripts/tests/deploy/utils/filesystem/report.sh @@ -6,7 +6,8 @@ set -euo pipefail LOG="${1:?usage: report.sh LOG}" -PATTERN='status=[a-z-]+ requested=[a-z]+ effective=[A-Za-z0-9_./-]+' +PATTERN='status=[a-z-]+ requested=[a-z0-9]+ effective=[A-Za-z0-9_./-]+' +REASON_PATTERN="${PATTERN} reason=[^']*" [ -f "${LOG}" ] || exit 0 [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 @@ -16,6 +17,16 @@ if ! VERDICTS="$(grep -ohE "${PATTERN}" "${LOG}" | sort | uniq -c | sort -rn)"; fi [ -n "${VERDICTS}" ] || exit 0 +REASONS="$(grep -ohE "${REASON_PATTERN}" "${LOG}" | sed -E 's/.* reason=//' | tr '|' '/' | sort -u)" || + REASONS="" + +APPLIED="$(printf '%s\n' "${VERDICTS}" | grep -c 'status=applied')" || APPLIED=0 +DECLINED="$(printf '%s\n' "${VERDICTS}" | grep -c 'status=declined')" || DECLINED=0 +LOST="" +if [ "${APPLIED}" -eq 0 ] && [ "${DECLINED}" -gt 0 ]; then + LOST="$(printf '%s\n' "${VERDICTS}" | sed -E 's/.*requested=([a-z0-9]+).*/\1/' | sort -u | paste -sd' ')" +fi + { echo "#### Docker data root, as actually used" echo @@ -23,6 +34,9 @@ fi echo "|---:|---|---|---|" # shellcheck disable=SC2016 echo "${VERDICTS}" | sed -E \ - 's/^ *([0-9]+) status=([a-z-]+) requested=([a-z]+) effective=(.+)$/| \1 | `\4` | `\3` | \2 |/' + 's/^ *([0-9]+) status=([a-z-]+) requested=([a-z0-9]+) effective=(.+)$/| \1 | `\4` | `\3` | \2 |/' echo + # shellcheck disable=SC2016 + [ -z "${LOST}" ] || printf '> [!WARNING]\n> No node applied `%s`; this job exercised none of it.\n\n' "${LOST}" + [ -z "${REASONS}" ] || printf '%s\n\n' "$(echo "${REASONS}" | sed -E 's/^/- /')" } >>"${GITHUB_STEP_SUMMARY}" diff --git a/scripts/tests/deploy/utils/filesystem/resolve.sh b/scripts/tests/deploy/utils/filesystem/resolve.sh index f666494d8d..4a50c47aa4 100755 --- a/scripts/tests/deploy/utils/filesystem/resolve.sh +++ b/scripts/tests/deploy/utils/filesystem/resolve.sh @@ -23,7 +23,11 @@ SCOPE="${4:?usage: resolve.sh STATED LABEL DISTROS SCOPE}" POOL="ext4 btrfs zfs" -note() { echo "filesystem: $*" >&2; } +note() { + echo "filesystem: $*" >&2 + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0 + echo "- \`${LABEL}\` filesystem: $*" >>"${GITHUB_STEP_SUMMARY}" +} kernel_serves() { local fs="$1" diff --git a/tests/lint/ansible/jinja/test_container_healthcheck_requires_probe.py b/tests/lint/ansible/jinja/test_container_healthcheck_requires_probe.py new file mode 100644 index 0000000000..ed183307f5 --- /dev/null +++ b/tests/lint/ansible/jinja/test_container_healthcheck_requires_probe.py @@ -0,0 +1,218 @@ +"""Strict guard: every ``lookup('container_healthcheck', key)`` call MUST +target a service whose ``meta/services.yml`` entry declares a probe - i.e. +``services..healthcheck`` exists and carries either a known ``flavor`` +or an explicit ``test`` argv. + +Why +=== + +``container_healthcheck`` (``plugins/lookup/container_healthcheck.py``) is +the SPOT for container probes: the call site passes only the service name, +everything else comes from the service's services config. A service that +declares no probe makes the lookup raise while the compose file is being +rendered: + + container_healthcheck: '' service '' declares no + healthcheck.flavor, so healthcheck.test is required. + +That only surfaces during a deploy, one role at a time. This lint replays +the lookup's own validation against the static config so the failure is +caught before CI ever deploys, and additionally rejects probe declarations +the lookup would refuse at render time (unknown flavor, or a ``flavor`` +and a ``test`` fighting over the same entry). + +Resolution scope: literal service keys, the ``service_name`` Jinja variable +resolved against the nearest preceding ``{% set service_name = ... %}``, +``entity_name``, ``application_id | get_entity_name``, and role var +constants (``CHESS_SERVICE``) whose ``vars/main.yml`` value resolves the +same way. Any other dynamic expression (loop variables) is skipped - it +cannot be resolved statically. + +Per-line opt-out: ``# nocheck: container-healthcheck-probe`` on the +offending line or the immediately preceding non-empty line. +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path +from typing import Any + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.applications import get_application_defaults +from utils.cache.files import iter_project_files_with_content +from utils.cache.yaml import load_yaml +from utils.docker.healthcheck import PROBES, known_flavors +from utils.roles.entity.name import get_entity_name +from utils.roles.mapping import ROLE_FILE_VARS_MAIN + +from . import PROJECT_ROOT + +_RULE = "container-healthcheck-probe" + +_SCAN_PREFIX = "roles/" +_SCAN_EXTENSIONS = (".j2",) + +_CALL = re.compile( + r"""lookup\(\s*['"]container_healthcheck['"]\s*,\s*([^,)]+?)\s*[,)]""" +) +_SET_SERVICE_NAME = re.compile(r"""\{%-?\s*set\s+service_name\s*=\s*(.+?)\s*-?%\}""") +_LITERAL = re.compile(r"""^['"]([^'"]+)['"]$""") +_INTERPOLATION = re.compile(r"""^\{\{\s*(.+?)\s*\}\}$""") +_ROLE_VAR = re.compile(r"^[A-Z][A-Z0-9_]*$") +_ENTITY_NAME_EXPRESSIONS = ("entity_name", "application_id | get_entity_name") + + +def _role_app_id(rel_path: str) -> str | None: + parts = Path(rel_path).parts + if len(parts) >= 2 and parts[0] == "roles": + return parts[1] + return None + + +def _resolve_expression(expression: str, app: str) -> str | None: + """Resolve a service-key expression to a literal key. + + Args: + expression: the Jinja expression the call site passed. + app: the application id owning the template. + """ + literal = _LITERAL.match(expression.strip()) + if literal: + return _resolve_expression(literal.group(1), app) or literal.group(1) + normalized = " ".join(expression.split()) + interpolation = _INTERPOLATION.match(normalized) + if interpolation: + return _resolve_expression(interpolation.group(1), app) + if normalized in _ENTITY_NAME_EXPRESSIONS: + return get_entity_name(app) + if _ROLE_VAR.match(normalized): + return _resolve_role_var(normalized, app) + return None + + +def _resolve_role_var(name: str, app: str) -> str | None: + """Resolve a role var constant against the role's ``vars/main.yml``. + + Args: + name: the upper-case var name the template referenced. + app: the application id owning the template. + """ + role_vars = load_yaml( + PROJECT_ROOT / "roles" / app / ROLE_FILE_VARS_MAIN, default_if_missing={} + ) + value = role_vars.get(name) + return _resolve_expression(value, app) if isinstance(value, str) else None + + +def _resolve_service_key( + argument: str, lines: list[str], index: int, app: str +) -> str | None: + """Resolve the service key a ``container_healthcheck`` call targets. + + Args: + argument: the call's first argument as written. + lines: the template's lines. + index: zero-based line index of the call. + app: the application id owning the template. + """ + if argument.strip() != "service_name": + return _resolve_expression(argument, app) + for line in reversed(lines[: index + 1]): + assignment = _SET_SERVICE_NAME.search(line) + if assignment: + return _resolve_expression(assignment.group(1), app) + return None + + +def _probe_defect(services: dict[str, Any], key: str) -> str | None: + """Report why a service's probe declaration would fail at render time. + + Args: + services: the application's ``services`` mapping. + key: the service key the call site targets. + """ + if key not in services: + return f"service '{key}' missing in services config" + config = services[key].get("healthcheck") + if not isinstance(config, dict): + return ( + f"service '{key}' declares no healthcheck.flavor, so healthcheck.test " + f"is required. Known flavors: {known_flavors()}" + ) + flavor = str(config.get("flavor", "") or "").strip() + if not flavor and not config.get("test"): + return ( + f"service '{key}' healthcheck has neither flavor nor test. " + f"Known flavors: {known_flavors()}" + ) + if flavor and flavor not in PROBES: + return f"service '{key}' has unknown flavor '{flavor}'" + if flavor and config.get("test"): + return ( + f"service '{key}' declares both flavor '{flavor}' and test; " + "the test would be ignored" + ) + return None + + +class TestContainerHealthcheckRequiresProbe(unittest.TestCase): + def test_healthcheck_call_sites_target_declared_probes(self) -> None: + apps = get_application_defaults() + findings: list[tuple[str, int, str]] = [] + for path_str, content in iter_project_files_with_content( + extensions=_SCAN_EXTENSIONS, + exclude_tests=True, + ): + rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix() + if not rel.startswith(_SCAN_PREFIX): + continue + app = _role_app_id(rel) + if app not in apps: + continue + lines = content.splitlines() + for index, line in enumerate(lines): + for match in _CALL.finditer(line): + key = _resolve_service_key(match.group(1), lines, index, app) + if key is None: + continue + defect = _probe_defect(apps[app].get("services", {}) or {}, key) + if defect and not is_suppressed_at( + lines, index + 1, _RULE, mode="same-or-above" + ): + findings.append((rel, index + 1, f"'{app}' {defect}")) + + if findings: + formatted = "\n".join( + f"- {p}:{n}: {msg}" + for p, n, msg in sorted(set(findings), key=lambda i: (i[0], i[1])) + ) + self.fail( + "container_healthcheck() calls target services without a usable " + "probe. Declare `healthcheck.flavor` (or an explicit " + "`healthcheck.test`) on the service in the role's " + "`meta/services.yml`, or mark a deliberate exception with " + f"`# nocheck: {_RULE}`.\n\nOffenders:\n{formatted}" + ) + + def test_declared_probes_are_renderable(self) -> None: + findings: list[str] = [] + for app, config in sorted(get_application_defaults().items()): + services = config.get("services", {}) or {} + for key in sorted(services): + if not isinstance(services[key].get("healthcheck"), dict): + continue + defect = _probe_defect(services, key) + if defect: + findings.append(f"- {app}: {defect}") + + if findings: + self.fail( + "services declare healthcheck blocks the container_healthcheck " + "lookup would refuse at render time:\n\n" + "\n".join(findings) + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/lint/ansible/roles/test_async_reason_documented.py b/tests/lint/ansible/roles/test_async_reason_documented.py index 32d2981f32..6489d193b3 100644 --- a/tests/lint/ansible/roles/test_async_reason_documented.py +++ b/tests/lint/ansible/roles/test_async_reason_documented.py @@ -29,6 +29,12 @@ Write it as ONE line directly above the ``async`` key: ``rationale:`` is a marker the comment lint accepts, so this does not count as narration. There is no ``nocheck`` escape: the requirement is one sentence, and a task that cannot be given one has not earned its ``async``. + +``async`` and ``poll`` must also be switched off by the SAME condition. The rule +reads the gate each key states inline - the test of an ``if ... else omit`` or +the left side of a ``| ternary(..., omit)`` - and requires the two to match. A +key that names no gate matches another that names none, since both then inherit +whatever the referenced variables do. """ from __future__ import annotations @@ -44,8 +50,12 @@ from . import PROJECT_ROOT ROLES_DIR = PROJECT_ROOT / "roles" _ASYNC_RE = re.compile(r"^(\s*)async:") +_POLL_RE = re.compile(r"^(\s*)poll:") _TASK_START_RE = re.compile(r"^\s*-\s") _REASON_RE = re.compile(r"#\s*rationale:\s*async;\s*\S") +_OMIT_RE = re.compile(r"\bomit\b") +_IF_ELSE_OMIT_RE = re.compile(r"if\s+(.+?)\s+else\s+omit") +_TERNARY_OMIT_RE = re.compile(r"\{\{\s*(.+?)\s*\|\s*ternary\(") def _task_files(): @@ -58,6 +68,18 @@ def _task_files(): yield path +def _gate(line: str) -> str | None: + """The off-switch a key states inline, normalised, or None when it states none. + + Args: + line: the ``async:`` or ``poll:`` line. + """ + if not _OMIT_RE.search(line): + return None + match = _IF_ELSE_OMIT_RE.search(line) or _TERNARY_OMIT_RE.search(line) + return " ".join(match.group(1).split()) if match else "omit" + + def _enclosing_task(lines: list[str], index: int) -> list[str]: """Return the lines of the task containing ``lines[index]``. @@ -108,6 +130,36 @@ class TestAsyncReasonDocumented(unittest.TestCase): + "\n".join(f" {o}" for o in offenders), ) + def test_async_and_poll_are_gated_by_the_same_condition(self): + offenders: list[str] = [] + + for path in _task_files(): + lines = read_text(str(path)).splitlines() + for i, line in enumerate(lines): + if not _ASYNC_RE.match(line): + continue + block = _enclosing_task(lines, i) + poll = next((e for e in block if _POLL_RE.match(e)), None) + if poll is None: + continue + if _gate(line) == _gate(poll): + continue + offenders.append( + f"{path.relative_to(PROJECT_ROOT)}:{i + 1}: " + f"{line.strip()} / {poll.strip()}" + ) + + self.assertEqual( + [], + offenders, + "async and poll must be switched off together.\n" + "Gating only poll leaves the task on the async_wrapper path with " + "ansible's built-in 15s poll, which collects the job and then runs " + "async_status mode=cleanup - deleting the job file a later reap " + "still expects to read, so a successful command reports as a " + "failed async job.\n" + "\n".join(f" {o}" for o in offenders), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/plugins/filter/test_async_failures.py b/tests/unit/plugins/filter/test_async_failures.py index c17b3981cf..03699c693b 100644 --- a/tests/unit/plugins/filter/test_async_failures.py +++ b/tests/unit/plugins/filter/test_async_failures.py @@ -76,6 +76,20 @@ class TestAsyncFailures(unittest.TestCase): async_failures(results, []), ) + def test_a_renamed_loop_var_is_still_named_in_the_report(self): + """loop_control.loop_var moves the loop value off the `item` key.""" + renamed = { + "finished": 1, + "rc": 1, + "stderr": "pull failed", + "ansible_job_id": "j5", + "item": { + "ansible_loop_var": "model", + "model": "smollm2:135m", + }, + } + self.assertEqual(["smollm2:135m: pull failed"], async_failures([renamed], [])) + def test_unreadable_job_is_a_failure_not_a_silent_pass(self): """async_status on a job started elsewhere returns finished:true with no outcome. diff --git a/tests/unit/plugins/lookup/test_compose_external_networks.py b/tests/unit/plugins/lookup/test_compose_external_networks.py index e554d5412e..5b89da1842 100644 --- a/tests/unit/plugins/lookup/test_compose_external_networks.py +++ b/tests/unit/plugins/lookup/test_compose_external_networks.py @@ -47,12 +47,10 @@ class TestComposeExternalNetworksLookup(unittest.TestCase): with ( mock.patch( - "plugins.lookup.compose_external_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch( - "plugins.lookup.compose_external_networks.lookup_loader" - ) as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.compose_external_networks.compute_external_network_roles", return_value=["svc-db-redis", "svc-db-openldap"], diff --git a/tests/unit/plugins/lookup/test_compose_networks.py b/tests/unit/plugins/lookup/test_compose_networks.py index 421f3b336c..438fb62ca5 100644 --- a/tests/unit/plugins/lookup/test_compose_networks.py +++ b/tests/unit/plugins/lookup/test_compose_networks.py @@ -55,10 +55,10 @@ class TestComposeNetworksLookup(unittest.TestCase): with ( mock.patch( - "plugins.lookup.compose_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch("plugins.lookup.compose_networks.lookup_loader") as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.compose_networks.render_compose_networks", return_value="RENDERED", @@ -72,7 +72,6 @@ class TestComposeNetworksLookup(unittest.TestCase): kwargs = render_mock.call_args.kwargs self.assertEqual(kwargs["application_id"], "web-app-baserow") self.assertEqual(kwargs["deployment_mode"], "swarm") - # swarm.network.encryption: False is honoured self.assertFalse(kwargs["swarm_encrypted"]) def test_swarm_encrypted_defaults_to_true_when_unset(self): @@ -84,10 +83,10 @@ class TestComposeNetworksLookup(unittest.TestCase): with ( mock.patch( - "plugins.lookup.compose_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch("plugins.lookup.compose_networks.lookup_loader") as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.compose_networks.render_compose_networks", return_value="RENDERED", @@ -99,11 +98,8 @@ class TestComposeNetworksLookup(unittest.TestCase): self.assertTrue(render_mock.call_args.kwargs["swarm_encrypted"]) def test_lookup_closures_pass_arguments_in_expected_order(self): - # Pin the call shape of the closures that the plugin synthesises: - # config_lookup.run([app, path, default], variables=vars_) - # database_lookup.run([app, key], variables=vars_) - # A regression that swaps app/path or drops the variables kwarg would - # break at runtime but stay green with naive MagicMock stubs. + """config_lookup.run([app, path, default]) and + database_lookup.run([app, key]), both with variables=vars_.""" vars_ = { "application_id": "web-app-x", "DEPLOYMENT_MODE": "swarm", @@ -122,17 +118,16 @@ class TestComposeNetworksLookup(unittest.TestCase): return [""] def _exercise(*_, lookup_config, lookup_database, **__): - # Render-side calls the closures; pretend it does so with known args. lookup_config("web-app-x", "services.sso.enabled", False) lookup_database("web-app-x", "id") return "RENDERED" with ( mock.patch( - "plugins.lookup.compose_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch("plugins.lookup.compose_networks.lookup_loader") as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.compose_networks.render_compose_networks", side_effect=_exercise, diff --git a/tests/unit/plugins/lookup/test_container_healthcheck.py b/tests/unit/plugins/lookup/test_container_healthcheck.py new file mode 100644 index 0000000000..5a8b68731b --- /dev/null +++ b/tests/unit/plugins/lookup/test_container_healthcheck.py @@ -0,0 +1,126 @@ +"""Unit tests for the container_healthcheck lookup plugin. + +Pins that values taken straight out of the play vars are templated before +use. ``include_role: vars:`` hands them over unrendered, so under +``sys-stk-full`` ``application_id`` arrives as the literal string +``{{ sys_stk_full_application_id }}`` and ``container_hostname`` as +``{{ lookup('domain', application_id) }}``. +""" + +from __future__ import annotations + +import importlib.util +import unittest + +from ansible.errors import AnsibleError + +from . import PROJECT_ROOT + +SERVICES = { + "web-app-dashboard": { + "services.dashboard.healthcheck": {"flavor": "tcp", "start_period": "10m"}, + "services.dashboard.ports.internal.http": 5000, + }, + "web-app-x": { + "services.web.healthcheck": {"flavor": "curl", "samples": 2}, + "services.web.ports.internal.http": 8080, + }, + "web-app-bare": {}, +} + + +def _load_lookup(): + spec = importlib.util.spec_from_file_location( + "lookup_container_healthcheck", + str(PROJECT_ROOT / "plugins/lookup/container_healthcheck.py"), + ) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(mod) + return mod + + +class _DummyTemplar: + def __init__(self, available_variables=None, rendered=None): + self.available_variables = available_variables or {} + self.rendered = rendered or {} + + def template(self, value): + return self.rendered[value] + + +class _DummyConfigLookup: + def run(self, terms, variables=None): + application_id, path, default = terms[0], terms[1], terms[2] + return [SERVICES.get(application_id, {}).get(path, default)] + + +class TestContainerHealthcheckLookup(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.mod = _load_lookup() + + def setUp(self): + self.original_loader = self.mod.lookup_loader + self.mod.lookup_loader = type( + "_Loader", (), {"get": staticmethod(lambda *a, **kw: _DummyConfigLookup())} + )() + + def tearDown(self): + self.mod.lookup_loader = self.original_loader + + def _make(self, variables, rendered=None): + lm = self.mod.LookupModule() + lm._templar = _DummyTemplar(variables, rendered) + lm._loader = None + return lm + + def test_unrendered_application_id_is_templated(self): + vars_ = { + "application_id": "{{ sys_stk_full_application_id }}", + "sys_stk_full_application_id": "web-app-dashboard", + } + rendered = {"{{ sys_stk_full_application_id }}": "web-app-dashboard"} + block = self._make(vars_, rendered).run(["dashboard"], variables=vars_)[0] + self.assertIn("/dev/tcp/localhost/5000", block) + self.assertIn("start_period: 10m", block) + + def test_unrendered_container_hostname_is_templated(self): + vars_ = { + "application_id": "web-app-x", + "container_hostname": "{{ lookup('domain', application_id) }}", + } + rendered = {"{{ lookup('domain', application_id) }}": "x.example.com"} + block = self._make(vars_, rendered).run(["web"], variables=vars_)[0] + self.assertIn("Host: x.example.com", block) + self.assertNotIn("lookup(", block) + + def test_plain_application_id_passes_through(self): + vars_ = {"application_id": "web-app-dashboard"} + block = self._make(vars_).run(["dashboard"], variables=vars_)[0] + self.assertIn("/dev/tcp/localhost/5000", block) + + def test_missing_flavor_and_test_raises(self): + vars_ = {"application_id": "web-app-bare"} + with self.assertRaises(AnsibleError) as ctx: + self._make(vars_).run(["bare"], variables=vars_) + self.assertIn("web-app-bare", str(ctx.exception)) + + def test_missing_application_id_raises(self): + with self.assertRaises(AnsibleError): + self._make({}).run(["web"], variables={}) + + def test_missing_service_name_raises(self): + with self.assertRaises(AnsibleError): + self._make({}).run([], variables={}) + + def test_unrendered_leftover_template_raises(self): + vars_ = {"application_id": "{{ untrusted }}"} + rendered = {"{{ untrusted }}": "{{ untrusted }}"} + with self.assertRaises(AnsibleError) as ctx: + self._make(vars_, rendered).run(["web"], variables=vars_) + self.assertIn("did not render", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/lookup/test_container_networks.py b/tests/unit/plugins/lookup/test_container_networks.py index 75e9d52b98..c93b32880c 100644 --- a/tests/unit/plugins/lookup/test_container_networks.py +++ b/tests/unit/plugins/lookup/test_container_networks.py @@ -51,12 +51,10 @@ class TestContainerNetworksLookup(unittest.TestCase): with ( mock.patch( - "plugins.lookup.container_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch( - "plugins.lookup.container_networks.lookup_loader" - ) as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.container_networks.render_container_networks", return_value="RENDERED", @@ -77,12 +75,10 @@ class TestContainerNetworksLookup(unittest.TestCase): with ( mock.patch( - "plugins.lookup.container_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch( - "plugins.lookup.container_networks.lookup_loader" - ) as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.container_networks.render_container_networks", return_value="RENDERED", @@ -94,11 +90,8 @@ class TestContainerNetworksLookup(unittest.TestCase): self.assertEqual(render_mock.call_args.kwargs["deployment_mode"], "compose") def test_lookup_closures_pass_arguments_in_expected_order(self): - # Pin the call shape of the closures that the plugin synthesises: - # config_lookup.run([app, path, default], variables=vars_) - # database_lookup.run([app, key], variables=vars_) - # A regression that swaps app/path or drops the variables kwarg would - # break at runtime but stay green with naive MagicMock stubs. + """config_lookup.run([app, path, default]) and + database_lookup.run([app, key]), both with variables=vars_.""" vars_ = { "application_id": "web-app-x", "DEPLOYMENT_MODE": "swarm", @@ -123,12 +116,10 @@ class TestContainerNetworksLookup(unittest.TestCase): with ( mock.patch( - "plugins.lookup.container_networks.build_service_registry_from_applications", + "utils.networks.lookup_context.build_service_registry_from_applications", return_value={}, ), - mock.patch( - "plugins.lookup.container_networks.lookup_loader" - ) as loader_mock, + mock.patch("utils.networks.lookup_context.lookup_loader") as loader_mock, mock.patch( "plugins.lookup.container_networks.render_container_networks", side_effect=_exercise, diff --git a/tests/unit/plugins/lookup/test_shared_network_key.py b/tests/unit/plugins/lookup/test_shared_network_key.py new file mode 100644 index 0000000000..1ee755dd1c --- /dev/null +++ b/tests/unit/plugins/lookup/test_shared_network_key.py @@ -0,0 +1,114 @@ +"""Unit tests for plugins/lookup/shared_network_key.py. + +Mocks the Ansible-side machinery and asserts that the plugin glue resolves the +term and chains ``shared_network_compose_key`` correctly. The key derivation +itself is covered by tests.unit.utils.networks.test_render. +""" + +from __future__ import annotations + +import unittest +from unittest import mock + +from ansible.errors import AnsibleError + +from plugins.lookup.shared_network_key import LookupModule + + +class _Templar: + def __init__(self, vars_=None): + self.available_variables = vars_ or {} + + def template(self, value, **_): + return value + + +def _make_lookup(vars_=None): + lm = LookupModule() + lm._templar = _Templar(vars_ or {}) + lm._loader = mock.MagicMock() + return lm + + +def _patched(lm, vars_, return_value="default"): + return ( + mock.patch( + "utils.networks.lookup_context.build_service_registry_from_applications", + return_value={}, + ), + mock.patch("utils.networks.lookup_context.lookup_loader"), + mock.patch( + "plugins.lookup.shared_network_key.shared_network_compose_key", + return_value=return_value, + ), + ) + + +class TestSharedNetworkKeyLookup(unittest.TestCase): + def test_raises_without_a_term(self): + lm = _make_lookup({"DEPLOYMENT_MODE": "swarm"}) + with self.assertRaises(AnsibleError): + lm.run([], variables={}) + + def test_raises_with_more_than_one_term(self): + lm = _make_lookup({"DEPLOYMENT_MODE": "swarm"}) + with self.assertRaises(AnsibleError): + lm.run(["a", "b"], variables={}) + + def test_returns_the_derived_key(self): + vars_ = {"DEPLOYMENT_MODE": "compose"} + lm = _make_lookup(vars_) + registry_patch, loader_patch, key_patch = _patched(lm, vars_, "seaweedfs") + with registry_patch, loader_patch as loader_mock, key_patch as key_mock: + loader_mock.get.return_value = mock.MagicMock(run=lambda *_a, **_k: [{}]) + result = lm.run(["web-app-seaweedfs"], variables=vars_) + + self.assertEqual(result, ["seaweedfs"]) + kwargs = key_mock.call_args.kwargs + self.assertEqual(kwargs["application_id"], "web-app-seaweedfs") + self.assertEqual(kwargs["deployment_mode"], "compose") + self.assertFalse(kwargs["node_local"]) + + def test_compose_mode_force_overrides_deployment_mode(self): + vars_ = {"DEPLOYMENT_MODE": "swarm", "compose_mode_force": "compose"} + lm = _make_lookup(vars_) + registry_patch, loader_patch, key_patch = _patched(lm, vars_) + with registry_patch, loader_patch as loader_mock, key_patch as key_mock: + loader_mock.get.return_value = mock.MagicMock(run=lambda *_a, **_k: [{}]) + lm.run(["web-app-x"], variables=vars_) + + self.assertEqual(key_mock.call_args.kwargs["deployment_mode"], "compose") + + def test_node_local_is_forwarded(self): + vars_ = {"DEPLOYMENT_MODE": "swarm"} + lm = _make_lookup(vars_) + registry_patch, loader_patch, key_patch = _patched(lm, vars_) + with registry_patch, loader_patch as loader_mock, key_patch as key_mock: + loader_mock.get.return_value = mock.MagicMock(run=lambda *_a, **_k: [{}]) + lm.run(["web-app-x"], variables=vars_, node_local=True) + + self.assertTrue(key_mock.call_args.kwargs["node_local"]) + + def test_lookup_closures_pass_arguments_in_expected_order(self): + vars_ = {"DEPLOYMENT_MODE": "swarm"} + lm = _make_lookup(vars_) + seen: list[tuple] = [] + + def _runner(terms, variables=None): + seen.append((tuple(terms), variables is vars_)) + return [{}] + + registry_patch, loader_patch, key_patch = _patched(lm, vars_) + with registry_patch, loader_patch as loader_mock, key_patch as key_mock: + loader_mock.get.return_value = mock.MagicMock(run=_runner) + lm.run(["web-app-x"], variables=vars_) + kwargs = key_mock.call_args.kwargs + kwargs["lookup_config"]("app", "networks.local.subnet", "fallback") + kwargs["lookup_database"]("app", "name") + + self.assertIn((("app", "networks.local.subnet", "fallback"), True), seen) + self.assertIn((("app", "name"), True), seen) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/utils/networks/test_render.py b/tests/unit/utils/networks/test_render.py index 5f8e661dcb..013b94bdcb 100644 --- a/tests/unit/utils/networks/test_render.py +++ b/tests/unit/utils/networks/test_render.py @@ -21,6 +21,7 @@ from utils.networks.render import ( compute_external_network_roles, render_compose_networks, render_container_networks, + shared_network_compose_key, ) @@ -780,5 +781,91 @@ class TestOwnSharedNetProvider(unittest.TestCase): self.assertNotIn("- seaweedfs", sidecar) +class TestSharedNetworkComposeKey(unittest.TestCase): + """The key under which the pre-created ```` network appears in the + rendered file. Callers stamp it into ``com.docker.compose.network``; docker + compose resolves a project's networks against that label, so a key that does + not match the rendered file makes compose adopt the wrong network and skip + creating the right one.""" + + def _key(self, application_id, registry, deployment_mode="compose"): + return shared_network_compose_key( + application_id=application_id, + deployment_mode=deployment_mode, + registry=registry, + get_entity_name=_entity_name, + lookup_config=_const_lookup_config(), + lookup_database=_const_lookup_database(), + ) + + def test_own_shared_net_provider_keys_on_its_entity(self): + for mode in ("compose", "swarm"): + with self.subTest(mode=mode): + self.assertEqual( + self._key("web-app-seaweedfs", _PROVIDER_REGISTRY, mode), + "seaweedfs", + ) + + def test_plain_app_keys_on_default(self): + for mode in ("compose", "swarm"): + with self.subTest(mode=mode): + self.assertEqual(self._key("web-app-baserow", {}, mode), "default") + + def test_consumer_of_a_foreign_provider_keys_on_default(self): + registry = { + "ldap": { + "role": "svc-db-openldap", + "entity_name": "openldap", + "provides": "ldap", + "overlay": {"modes": ["compose"], "topology": "shared_net"}, + }, + } + self.assertEqual(self._key("web-app-baserow", registry), "default") + + def test_node_local_forces_the_compose_answer(self): + self.assertEqual( + shared_network_compose_key( + application_id="web-app-seaweedfs", + deployment_mode="swarm", + registry=_PROVIDER_REGISTRY, + get_entity_name=_entity_name, + lookup_config=_const_lookup_config(), + lookup_database=_const_lookup_database(), + node_local=True, + ), + "seaweedfs", + ) + + def test_key_matches_the_key_the_renderer_emits(self): + """The contract that makes this a single source: whatever key the + function reports must be the key the rendered networks block actually + uses for the entity network.""" + cases = ( + ("web-app-seaweedfs", _PROVIDER_REGISTRY), + ("web-app-baserow", {}), + ) + for application_id, registry in cases: + for mode in ("compose", "swarm"): + with self.subTest(application_id=application_id, mode=mode): + key = self._key(application_id, registry, mode) + rendered = render_compose_networks( + application_id=application_id, + deployment_mode=mode, + registry=registry, + get_entity_name=_entity_name, + lookup_config=_const_lookup_config( + **{"networks.local.subnet": "192.168.206.0/24"} + ), + lookup_database=_const_lookup_database(), + ) + self.assertIn(f" {key}:", rendered) + entity = _entity_name(application_id) + if key == "default": + self.assertIn(f" name: {entity}", rendered) + else: + self.assertEqual(key, entity) + self.assertNotIn(f" name: {entity}", rendered) + + if __name__ == "__main__": unittest.main() diff --git a/utils/diagnostics/container.py b/utils/diagnostics/container.py index 929bb092df..b40dce96b6 100755 --- a/utils/diagnostics/container.py +++ b/utils/diagnostics/container.py @@ -111,6 +111,12 @@ def collect_host(out: Path, app_id: str, context: str, stamp: str) -> None: "resolve-probe.txt", ["getent", "hosts", "deb.debian.org", "ghcr.io", "repo.packagist.org"], ), + ("modules.txt", ["cat", "/proc/modules"]), + ("devices.txt", ["cat", "/proc/devices", "/proc/misc"]), + ("zfs-dev.txt", ["ls", "-l", "/dev/zfs"]), + ("zfs-version.txt", ["zfs", "version"]), + ("net-snmp.txt", ["cat", "/proc/net/snmp", "/proc/net/netstat"]), + ("conntrack-stat.txt", ["cat", "/proc/net/stat/nf_conntrack"]), ): capture(out, name, cmd) dmesg = run(["dmesg", "-T"]).stdout.decode(errors="replace") diff --git a/utils/networks/attachments.py b/utils/networks/attachments.py new file mode 100644 index 0000000000..32b750acf8 --- /dev/null +++ b/utils/networks/attachments.py @@ -0,0 +1,141 @@ +"""Attachment computation for the compose networks renderers. + +Which provider overlays a role attaches to, and which proxy aliases the +default network harvests, derived from the service_registry. Kept apart from +the emitters in :mod:`utils.networks.render` so a change to the consumer rules +cannot be confused with a change to the YAML they produce. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + +def _is_consumer( + entry: dict[str, Any], + application_id: str, + lookup_config: Callable[[str, str, Any], Any], + lookup_database: Callable[[str, str], Any], +) -> bool: + overlay = entry.get("overlay") or {} + consumer = overlay.get("consumer") or {} + kind = consumer.get("kind") or "services_flags" + if kind == "database": + if not _coerce_bool(lookup_database(application_id, "enabled")): + return False + if not _coerce_bool(lookup_database(application_id, "shared")): + return False + return lookup_database(application_id, "id") == entry.get("role") + if kind == "services_flags": + key = consumer.get("key") or entry.get("provides") or entry.get("entity_name") + flags = consumer.get("flags") or ["enabled", "shared"] + for flag in flags: + if not _coerce_bool( + lookup_config(application_id, f"services.{key}.{flag}", False) + ): + return False + return True + if kind == "web_facing": + return application_id.startswith(("web-app-", "web-svc-")) + return False + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes") + return bool(value) + + +def _compute_attachments( + registry: dict[str, dict[str, Any]], + application_id: str, + deployment_mode: str, + lookup_config: Callable[[str, str, Any], Any], + lookup_database: Callable[[str, str], Any], +) -> tuple[list[dict[str, Any]], list[str]]: + attachments: list[dict[str, Any]] = [] + default_aliases: list[str] = [] + + for entry in registry.values(): + if "canonical" in entry: + continue + overlay = entry.get("overlay") + if not overlay: + continue + if deployment_mode not in overlay.get("modes", []): + continue + + is_provider = application_id == entry.get("role") + topology = overlay.get("topology") + + if is_provider: + if topology == "default_net": + default_aliases.extend(overlay.get("aliases") or []) + for peer in registry.values(): + peer_overlay = peer.get("overlay") + if not peer_overlay: + continue + if not peer_overlay.get("proxy_resolvable"): + continue + if deployment_mode not in peer_overlay.get("modes", []): + continue + if "canonical" in peer: + continue + if peer.get("role") == entry.get("role"): + continue + default_aliases.extend( + peer_overlay.get("proxy_aliases") + or peer_overlay.get("aliases") + or [] + ) + elif topology: + aliases = list( + overlay.get("aliases", [entry.get("entity_name")]) + or [entry.get("entity_name")] + ) + if overlay.get("collect_proxy_resolvable"): + for peer in registry.values(): + peer_overlay = peer.get("overlay") + if not peer_overlay: + continue + if not peer_overlay.get("proxy_resolvable"): + continue + if deployment_mode not in peer_overlay.get("modes", []): + continue + if "canonical" in peer: + continue + if peer.get("role") == entry.get("role"): + continue + aliases.extend( + peer_overlay.get("proxy_aliases") + or peer_overlay.get("aliases") + or [] + ) + attachments.append( + { + "role": entry["role"], + "topology": topology, + "aliases": aliases, + "is_provider": True, + } + ) + continue + + if not topology: + continue + if _is_consumer(entry, application_id, lookup_config, lookup_database): + attachments.append( + { + "role": entry["role"], + "topology": topology, + "aliases": [], + "is_provider": False, + } + ) + + return attachments, default_aliases diff --git a/utils/networks/lookup_context.py b/utils/networks/lookup_context.py new file mode 100644 index 0000000000..910eaa35e4 --- /dev/null +++ b/utils/networks/lookup_context.py @@ -0,0 +1,98 @@ +"""Shared Ansible-side assembly for the network lookup plugins. + +Every one of them needs the same four things before it can call into +:mod:`utils.networks.render`: the resolved variables, the deployment mode, a +service registry built from the merged applications, and closures over the +``config`` and ``database`` lookups. Building that once means a change to how +the registry or the closures are obtained cannot reach one lookup and miss +another. + +Args of :func:`build_context` are documented on the function. +""" + +from __future__ import annotations + +import contextlib +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from ansible.plugins.loader import lookup_loader + +from utils.roles.applications.services.registry import ( + build_service_registry_from_applications, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + +def resolve_var(templar: Any, value: Any) -> Any: + """Template ``value`` when a templar is available, else pass it through. + + Args: + templar: the plugin's templar, or None. + value: raw variable value. + """ + if templar is None: + return value + with contextlib.suppress(Exception): + return templar.template(value) + return value + + +@dataclass(frozen=True) +class NetworkLookupContext: + """What a network lookup needs to call the renderers.""" + + vars: dict[str, Any] + templar: Any + deployment_mode: str + registry: dict[str, dict[str, Any]] + lookup_config: Callable[[str, str, Any], Any] + lookup_database: Callable[[str, str], Any] + + +def build_context( + lookup: Any, + variables: dict[str, Any] | None, + *, + honour_mode_force: bool = True, +) -> NetworkLookupContext: + """Assemble the shared context for a network lookup. + + Args: + lookup: the LookupModule instance, for its templar and loader. + variables: the variables Ansible passed to ``run``. + honour_mode_force: let ``compose_mode_force`` override DEPLOYMENT_MODE. + """ + templar = getattr(lookup, "_templar", None) + vars_ = variables or getattr(templar, "available_variables", {}) or {} + + deployment_mode = str(resolve_var(templar, vars_.get("DEPLOYMENT_MODE", "compose"))) + if honour_mode_force: + mode_force = resolve_var(templar, vars_.get("compose_mode_force", "")) + deployment_mode = str(mode_force or deployment_mode).strip() + + applications = lookup_loader.get( + "applications", loader=lookup._loader, templar=templar + ).run([], variables=vars_)[0] + + config_lookup = lookup_loader.get("config", loader=lookup._loader, templar=templar) + database_lookup = lookup_loader.get( + "database", loader=lookup._loader, templar=templar + ) + + def _lookup_config(app: str, path: str, default: Any) -> Any: + return config_lookup.run([app, path, default], variables=vars_)[0] + + def _lookup_database(app: str, key: str) -> Any: + return database_lookup.run([app, key], variables=vars_)[0] + + return NetworkLookupContext( + vars=vars_, + templar=templar, + deployment_mode=deployment_mode, + registry=build_service_registry_from_applications(applications), + lookup_config=_lookup_config, + lookup_database=_lookup_database, + ) diff --git a/utils/networks/render.py b/utils/networks/render.py index 2df5832f57..4ff2747da0 100644 --- a/utils/networks/render.py +++ b/utils/networks/render.py @@ -32,131 +32,21 @@ if TYPE_CHECKING: from collections.abc import Callable -def _is_consumer( - entry: dict[str, Any], - application_id: str, - lookup_config: Callable[[str, str, Any], Any], - lookup_database: Callable[[str, str], Any], -) -> bool: - overlay = entry.get("overlay") or {} - consumer = overlay.get("consumer") or {} - kind = consumer.get("kind") or "services_flags" - if kind == "database": - if not _coerce_bool(lookup_database(application_id, "enabled")): - return False - if not _coerce_bool(lookup_database(application_id, "shared")): - return False - return lookup_database(application_id, "id") == entry.get("role") - if kind == "services_flags": - key = consumer.get("key") or entry.get("provides") or entry.get("entity_name") - flags = consumer.get("flags") or ["enabled", "shared"] - for flag in flags: - if not _coerce_bool( - lookup_config(application_id, f"services.{key}.{flag}", False) - ): - return False - return True - if kind == "web_facing": - return application_id.startswith(("web-app-", "web-svc-")) - return False +from utils.networks.attachments import ( + _coerce_bool, + _compute_attachments, + _is_consumer, +) - -def _coerce_bool(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in ("true", "1", "yes") - return bool(value) - - -def _compute_attachments( - registry: dict[str, dict[str, Any]], - application_id: str, - deployment_mode: str, - lookup_config: Callable[[str, str, Any], Any], - lookup_database: Callable[[str, str], Any], -) -> tuple[list[dict[str, Any]], list[str]]: - attachments: list[dict[str, Any]] = [] - default_aliases: list[str] = [] - - for entry in registry.values(): - if "canonical" in entry: - continue - overlay = entry.get("overlay") - if not overlay: - continue - if deployment_mode not in overlay.get("modes", []): - continue - - is_provider = application_id == entry.get("role") - topology = overlay.get("topology") - - if is_provider: - if topology == "default_net": - default_aliases.extend(overlay.get("aliases") or []) - for peer in registry.values(): - peer_overlay = peer.get("overlay") - if not peer_overlay: - continue - if not peer_overlay.get("proxy_resolvable"): - continue - if deployment_mode not in peer_overlay.get("modes", []): - continue - if "canonical" in peer: - continue - if peer.get("role") == entry.get("role"): - continue - default_aliases.extend( - peer_overlay.get("proxy_aliases") - or peer_overlay.get("aliases") - or [] - ) - elif topology: - aliases = list( - overlay.get("aliases", [entry.get("entity_name")]) - or [entry.get("entity_name")] - ) - if overlay.get("collect_proxy_resolvable"): - for peer in registry.values(): - peer_overlay = peer.get("overlay") - if not peer_overlay: - continue - if not peer_overlay.get("proxy_resolvable"): - continue - if deployment_mode not in peer_overlay.get("modes", []): - continue - if "canonical" in peer: - continue - if peer.get("role") == entry.get("role"): - continue - aliases.extend( - peer_overlay.get("proxy_aliases") - or peer_overlay.get("aliases") - or [] - ) - attachments.append( - { - "role": entry["role"], - "topology": topology, - "aliases": aliases, - "is_provider": True, - } - ) - continue - - if not topology: - continue - if _is_consumer(entry, application_id, lookup_config, lookup_database): - attachments.append( - { - "role": entry["role"], - "topology": topology, - "aliases": [], - "is_provider": False, - } - ) - - return attachments, default_aliases +__all__ = [ + "_coerce_bool", + "_compute_attachments", + "_is_consumer", + "compute_external_network_roles", + "render_compose_networks", + "render_container_networks", + "shared_network_compose_key", +] def _suppress_default(application_id: str) -> bool: @@ -176,6 +66,53 @@ def _own_shared_net_provider( ) +def _shared_network_key( + attachments: list[dict[str, Any]], + own_entity: str, + get_entity_name: Callable[[str], str], +) -> str: + if _own_shared_net_provider(attachments, own_entity, get_entity_name): + return own_entity + return "default" + + +def shared_network_compose_key( + *, + application_id: str, + deployment_mode: str, + registry: dict[str, dict[str, Any]], + get_entity_name: Callable[[str], str], + lookup_config: Callable[[str, str, Any], Any], + lookup_database: Callable[[str, str], Any], + node_local: bool = False, +) -> str: + """Key under which the pre-created ```` network appears in the + rendered compose file, which is what docker compose stores in the + ``com.docker.compose.network`` label. An app that provides its own + ``shared_net`` gets that network under its entity key and renders a separate + unnamed ``default``; every other app renders ``default`` with the entity as + its name. :func:`render_compose_networks` derives its own branch from this + same call, so the label a caller stamps cannot drift from what is rendered. + + Args: + application_id: role the network belongs to. + deployment_mode: compose or swarm. + registry: service registry built from the merged applications. + get_entity_name: role id to entity name. + lookup_config: config value accessor. + lookup_database: database value accessor. + node_local: render as compose regardless of deployment_mode. + """ + if node_local: + deployment_mode = "compose" + attachments, _ = _compute_attachments( + registry, application_id, deployment_mode, lookup_config, lookup_database + ) + return _shared_network_key( + attachments, get_entity_name(application_id), get_entity_name + ) + + def compute_external_network_roles( *, application_id: str, @@ -229,8 +166,8 @@ def render_compose_networks( lines.append(" external: true") own_entity = get_entity_name(application_id) - is_own_shared_net_provider = _own_shared_net_provider( - attachments, own_entity, get_entity_name + is_own_shared_net_provider = ( + _shared_network_key(attachments, own_entity, get_entity_name) == own_entity ) if not _suppress_default(application_id): lines.append(" default:") diff --git a/utils/tests/swarm/matrix.py b/utils/tests/swarm/matrix.py index 5857bc39c1..20e5d9c760 100644 --- a/utils/tests/swarm/matrix.py +++ b/utils/tests/swarm/matrix.py @@ -33,6 +33,7 @@ _SWARM_SCRIPTS = _SWARM_DIR / "routine" _ROLES_DIR = str(PROJECT_ROOT / "roles") _SWARM_EXTRAS_VARS = "inventories/development/swarm.yml" _DEFAULT_INVENTORY_DIR = "/tmp/inv" # noqa: S108 - ephemeral swarm-test inventory base in CI +DISK_FLOOR_MB = 6 * 2**10 def _run(cmd: list[str], *, env: dict[str, str], label: str) -> int: @@ -40,7 +41,7 @@ def _run(cmd: list[str], *, env: dict[str, str], label: str) -> int: The Actions Worker dies silently on ENOSPC while writing its own logs, so a full disk truncates the job without diagnostics; terminating the step at - <6G free keeps enough room for rescue artifacts and the log upload. + DISK_FLOOR_MB keeps enough room for rescue artifacts and the log upload. """ print(f"=== swarm-matrix: {label} ===", flush=True) proc = subprocess.Popen(cmd, cwd=str(PROJECT_ROOT), env=env) @@ -48,10 +49,10 @@ def _run(cmd: list[str], *, env: dict[str, str], label: str) -> int: try: return int(proc.wait(timeout=30)) except subprocess.TimeoutExpired: - if shutil.disk_usage("/").free < 6 * 2**30: + if shutil.disk_usage("/").free < DISK_FLOOR_MB * 2**20: print( "=== swarm-matrix: DISK EXHAUSTION IMMINENT " - "(<6G free on /) - aborting step ===", + f"(<{DISK_FLOOR_MB}M free on /) - aborting step ===", flush=True, ) subprocess.run(["df", "-h", "/"], check=False) @@ -198,6 +199,7 @@ def _backup_restore_drill(*, app_id: str, inv_dir: str, extras_path: str) -> int env["APP_ID"] = app_id env["INFINITO_INVENTORY_DIR"] = inv_dir env["DRILL_EXTRAS"] = extras_path + env["DISK_FLOOR_MB"] = str(DISK_FLOOR_MB) return _run( ["bash", str(_SWARM_SCRIPTS / "backup" / "base.sh")], env=env,