Merge branch 'feature/swarm-nfs' into feature/ai-agent-employees

This commit is contained in:
kevinveenbirkenbach committed 2026-08-03 22:24:36 +02:00
commit aeefb14076
48 files changed
+1262 -442

No files matched your search

+13 -4
View File
@@ -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; \
+6 -3
View File
@@ -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"):
+12 -46
View File
@@ -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,
)
]
+17 -52
View File
@@ -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)),
)
]
+28 -3
View File
@@ -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.
+21 -57
View File
@@ -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.<svc>`` 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)),
)
]
+60
View File
@@ -0,0 +1,60 @@
"""Lookup `shared_network_key`: the compose networks key of the pre-created
``<entity>`` 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)),
)
]
@@ -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"
+1
View File
@@ -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
@@ -62,6 +62,7 @@
changed_when: false
failed_when: false
ignore_errors: true
delegate_to: "{{ STACK_HOST }}"
- ansible.builtin.debug:
msg: |
@@ -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
@@ -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 }}"
@@ -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
@@ -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}"
@@ -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 -%}
@@ -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 }}"
@@ -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
@@ -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/<step>.yml` file owns exactly one OCC stage and one decision about whether to continue.
Each `tasks/02_manager_ops/05_plugin/<step>.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).
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -0,0 +1,24 @@
<?php
{%- macro php_str(value) -%}
'{{ value | replace("\\", "\\\\") | replace("'", "\\'") }}'
{%- endmacro -%}
{%- set _legacy_login = lookup('config', application_id, 'services.nextcloud.legacy_login_mask.enabled') | bool %}
return [
'hide_login_form' => {{ '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 %}
],
];
-38
View File
@@ -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 }}"
@@ -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
@@ -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
@@ -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"
@@ -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}"
@@ -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"
@@ -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.<key>.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: '<app>' service '<key>' 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()
@@ -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()
@@ -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.
@@ -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"],
@@ -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,
@@ -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()
@@ -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,
@@ -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()
+87
View File
@@ -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 ``<entity>`` 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()
+6
View File
@@ -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")
+141
View File
@@ -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
+98
View File
@@ -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,
)
+63 -126
View File
@@ -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 ``<entity>`` 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:")
+5 -3
View File
@@ -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,