mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Merge branch 'feature/swarm-nfs' into feature/ai-agent-employees
This commit is contained in:
48 files changed
+1262
-442
No files matched your search
@@ -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; \
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
]
|
||||
@@ -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)),
|
||||
)
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-3
@@ -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
|
||||
File renamed without changes.
@@ -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 %}
|
||||
],
|
||||
];
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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:")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user