feat(constructor): stop a deploy in a pre-flight when the host cannot carry it

The constructor now runs tasks/utils/constructor/preflight.yml before the package install and fails with a named remedy when: the running kernel has no module index on disk or the clock is not synchronised (chrony leap status, else timedatectl, outside containers); / or the docker root is fuller than SIZE_PERCENT_MAXIMUM_DISK_USAGE (90); a docker network contradicts the entity that declares it (name, compose project label, subnet outside its block, overlap with another declared subnet); a declared container lacks or a declared volume carries a foreign entity label; a docker default address pool overlaps the mesh, Tor or a declared subnet; a host process outside docker holds a public port of the deploy; or, in assert mode, a clearnet domain of the deploy does not resolve.

A volume without any label passes, since compose adopts it and installs before this change created the Synapse volume with docker run. web-app-discourse declares its launcher-started containers through networks.local.launcher, and web-app-matrix labels a new Synapse volume with its compose project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-03 13:38:15 +01:00
1 parent 9ad3fe32d9
commit a67dd9b372
39 files changed
+2400 -2

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
---
SIZE_PERCENT_MAXIMUM_DISK_USAGE: 90 # Disk usage in percent above which Loki refuses pushes and the pre-flight stops the deploy
+89
View File
@@ -0,0 +1,89 @@
"""Filters that read the host probes of the constructor pre-flight."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ansible.errors import AnsibleFilterError
from utils.preflight.host import disk_usage_errors
from utils.preflight.ports import (
containerised_pids,
parse_listeners,
port_conflicts,
probed_pids,
suspect_pids,
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
def _ports(ports: Mapping[str, Sequence[str]]) -> dict[int, list[str]]:
return {int(port): list(owners) for port, owners in ports.items()}
def preflight_disk_usage(
df_output: str, paths: Sequence[str], maximum: Any
) -> list[str]:
"""Every probed path whose filesystem is fuller than the limit.
Args:
df_output: the stdout of ``df -P -- <paths>``.
paths: the paths ``df`` was asked about, in that order.
maximum: the highest usage in percent a filesystem may reach.
"""
try:
return disk_usage_errors(list(paths), str(df_output), int(maximum))
except ValueError as exc:
raise AnsibleFilterError(f"preflight_disk_usage: {exc}") from exc
def preflight_port_cgroups(
ss_output: str, ports: Mapping[str, Sequence[str]]
) -> list[str]:
"""The cgroup files of the host processes on a public port of the deploy.
Args:
ss_output: the stdout of ``ss -Htlnpu``.
ports: the ``lookup('public_ports', group_names)`` mapping.
Returns:
``/proc/<pid>/cgroup`` of every process other than docker's own that
listens on one of ``ports``; empty when there is none.
"""
try:
listeners = parse_listeners(str(ss_output))
except ValueError as exc:
raise AnsibleFilterError(f"preflight_port_cgroups: {exc}") from exc
return [f"/proc/{pid}/cgroup" for pid in suspect_pids(listeners, _ports(ports))]
def preflight_port_conflicts(
ss_output: str, cgroup_probe: Mapping[str, Any], ports: Mapping[str, Sequence[str]]
) -> list[str]:
"""Every public port of the deploy a host process outside docker holds.
Args:
ss_output: the stdout of ``ss -Htlnpu``.
cgroup_probe: the registered result of ``grep -H . <cgroup files>``
over :func:`preflight_port_cgroups`; skipped when that was empty.
ports: the ``lookup('public_ports', group_names)`` mapping.
"""
try:
listeners = parse_listeners(str(ss_output))
except ValueError as exc:
raise AnsibleFilterError(f"preflight_port_conflicts: {exc}") from exc
cgroups = "" if cgroup_probe.get("skipped") else str(cgroup_probe["stdout"])
return port_conflicts(
listeners, containerised_pids(cgroups), _ports(ports), probed_pids(cgroups)
)
class FilterModule:
def filters(self) -> dict[str, Any]:
return {
"preflight_disk_usage": preflight_disk_usage,
"preflight_port_cgroups": preflight_port_cgroups,
"preflight_port_conflicts": preflight_port_conflicts,
}
+74
View File
@@ -0,0 +1,74 @@
"""Lookup ``address_pool_overlaps``: the docker default address pools of a
host that overlap a range the deploy addresses itself.
{{ lookup('address_pool_overlaps', daemon_json, group_names, reserved) }}
Args:
daemon_json: the text of ``/etc/docker/daemon.json``; ``{}`` when the
file is absent.
group_names: every group of the host, after the swarm auto-placement.
reserved: name -> CIDR of every further range no pool may overlap, such
as ``NETWORK_MESH_POOL`` and ``NETWORK_TOR_VIRTUAL_CIDR``.
Returns:
The messages of :func:`utils.preflight.pools.address_pool_errors` against
``reserved`` and the ``networks.local.subnet`` of every role the deploy
may run on the host.
"""
from __future__ import annotations
import json
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.preflight.context import deploy_closure_context
from utils.preflight.pools import address_pool_errors
from utils.roles.entity.name import entity_name
_SUBNET_PATH = "networks.local.subnet"
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[list[str]]:
if not terms or len(terms) != 3:
raise AnsibleError(
"lookup('address_pool_overlaps', daemon_json, group_names, reserved) "
"expects exactly 3 terms"
)
daemon_json, group_names, reserved = terms
try:
daemon_config = json.loads(str(daemon_json))
except json.JSONDecodeError as exc:
raise AnsibleError(
f"address_pool_overlaps: daemon.json is not JSON: {exc}"
) from exc
if not isinstance(daemon_config, dict) or not isinstance(reserved, dict):
raise AnsibleError(
"address_pool_overlaps: daemon.json and reserved must both be mappings"
)
try:
context = deploy_closure_context(self, variables, group_names)
except (TypeError, ValueError) as exc:
raise AnsibleError(f"address_pool_overlaps: {exc}") from exc
ranges = [
(f"{name} reserves", str(cidr)) for name, cidr in sorted(reserved.items())
]
for role in context.roles:
subnet = str(context.config(role, _SUBNET_PATH, "") or "").strip()
if subnet:
ranges.append(
(f"{role} declares for its network '{entity_name(role)}'", subnet)
)
try:
return [address_pool_errors(daemon_config, ranges)]
except ValueError as exc:
raise AnsibleError(f"address_pool_overlaps: {exc}") from exc
+43
View File
@@ -0,0 +1,43 @@
"""Lookup ``deploy_domains``: the clearnet domains the deploy serves on a host.
{{ lookup('deploy_domains', group_names) }}
Args:
group_names: every group of the host, after the swarm auto-placement.
Returns:
The :func:`utils.preflight.domains.clearnet_domains` of the canonical
domains of every role the deploy may run on the host.
"""
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.preflight.context import deploy_closure_context
from utils.preflight.domains import clearnet_domains
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[list[str]]:
if not terms or len(terms) != 1:
raise AnsibleError(
"lookup('deploy_domains', group_names) expects exactly 1 term"
)
try:
context = deploy_closure_context(self, variables, terms[0])
except (TypeError, ValueError) as exc:
raise AnsibleError(f"deploy_domains: {exc}") from exc
return [
clearnet_domains(
[context.lookup("domains", role, []) for role in context.roles]
)
]
+92
View File
@@ -0,0 +1,92 @@
"""Lookup ``docker_ownership``: the docker containers or volumes of a host
that do not belong to the entity of the role that declares them.
{{ lookup('docker_ownership', kind, probe, group_names) }}
Args:
kind: ``container`` or ``volume``.
probe: the registered result of ``container ps --all`` or
``container volume ls``, formatted as
``<names>\\t<compose project label>\\t<stack namespace label>``.
group_names: every group of the host, after the swarm auto-placement.
Returns:
The messages of :func:`utils.preflight.ownership.labelled_ownership_errors`
for every name a role the deploy may run on the host declares: the
``services.<key>.name`` of its containers, or the docker name of each
``type: volume`` entry of its ``meta/volumes.yml`` (the ``name`` field,
else ``<entity>_<key>`` as compose names it). A role whose
``meta/networks.yml`` sets ``local.launcher: true`` starts its containers
with its own launcher outside any compose project, so its container
names carry no label to compare.
"""
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.preflight.context import DeployClosureContext, deploy_closure_context
from utils.preflight.ownership import labelled_ownership_errors, parse_labelled
from utils.roles.entity.name import entity_name
_KINDS = ("container", "volume")
def _container_names(context: DeployClosureContext, role: str) -> list[str]:
if context.config(role, "networks.local.launcher", False) is True:
return []
services = (context.applications.get(role) or {}).get("services") or {}
names = []
for key, entry in sorted(services.items()):
if isinstance(entry, dict) and "name" in entry:
name = str(context.config(role, f"services.{key}.name", "") or "").strip()
if name:
names.append(name)
return names
def _volume_names(context: DeployClosureContext, role: str) -> list[str]:
registry = context.lookup("volume", role) or {}
return [
str(entry.get("name") or f"{entity_name(role)}_{key}")
for key, entry in sorted(registry.items())
if isinstance(entry, dict) and entry.get("type", "volume") == "volume"
]
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[list[str]]:
if not terms or len(terms) != 3:
raise AnsibleError(
"lookup('docker_ownership', kind, probe, group_names) expects exactly 3 terms"
)
kind, probe, group_names = terms
if kind not in _KINDS:
raise AnsibleError(
f"docker_ownership: kind must be one of {', '.join(_KINDS)}, got {kind!r}"
)
try:
objects = parse_labelled(probe["stdout"])
except (KeyError, TypeError, ValueError) as exc:
raise AnsibleError(
f"docker_ownership: unparsable {kind} probe: {exc}"
) from exc
try:
context = deploy_closure_context(self, variables, group_names)
except (TypeError, ValueError) as exc:
raise AnsibleError(f"docker_ownership: {exc}") from exc
names_of = _container_names if kind == "container" else _volume_names
declared: dict[str, list[tuple[str, str]]] = {}
for role in context.roles:
for name in names_of(context, role):
declared.setdefault(name, []).append((role, entity_name(role)))
return [labelled_ownership_errors(kind, objects, declared)]
+79
View File
@@ -0,0 +1,79 @@
"""Lookup ``network_ownership``: the docker networks of a host that do not
belong to their entity.
{{ lookup('network_ownership', probe, group_names) }}
Args:
probe: the registered result of ``container network inspect <every id>``.
group_names: every group of the host, after the swarm auto-placement.
Returns:
The messages of :func:`utils.networks.ownership.ownership_errors` for
every role the deploy may run on the host
(:func:`utils.roles.invocations.deploy_closure`) that declares
``networks.local.subnet``.
"""
from __future__ import annotations
import json
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils import PROJECT_ROOT
from utils.networks.ownership import ownership_errors
from utils.roles.entity.name import entity_name
from utils.roles.invocations import deploy_closure
from utils.roles.validation.invokable import types_from_group_names
_SUBNET_PATH = "networks.local.subnet"
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[list[str]]:
if not terms or len(terms) != 2:
raise AnsibleError(
"lookup('network_ownership', probe, group_names) expects exactly 2 terms"
)
probe, group_names = terms
try:
networks = json.loads(probe["stdout"])
except (KeyError, TypeError, json.JSONDecodeError) as exc:
raise AnsibleError(
f"network_ownership: unparsable network inspect probe: {exc}"
) from exc
groups = [str(group) for group in group_names]
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
templar = getattr(self, "_templar", None)
applications = lookup_loader.get(
"applications", loader=self._loader, templar=templar
).run([], variables=vars_)[0]
config = lookup_loader.get("config", loader=self._loader, templar=templar)
try:
roles = deploy_closure(
applications,
groups,
project_root=PROJECT_ROOT,
host_types=types_from_group_names(groups),
group_names=groups,
)
except (TypeError, ValueError) as exc:
raise AnsibleError(f"network_ownership: {exc}") from exc
declared: dict[str, list[tuple[str, str]]] = {}
for role in sorted(roles):
subnet = str(
config.run([role, _SUBNET_PATH, ""], variables=vars_)[0] or ""
).strip()
if subnet:
declared.setdefault(entity_name(role), []).append((role, subnet))
return [ownership_errors(networks, declared)]
+65
View File
@@ -0,0 +1,65 @@
"""Lookup ``public_ports``: the single public ports the deploy binds on a host.
{{ lookup('public_ports', group_names) }}
Args:
group_names: every group of the host, after the swarm auto-placement.
Returns:
Port (as a string key) -> every ``<role> (<service>.<name>)`` that
declares it under ``services.<service>.ports.public.<name>``, for every
role the deploy may run on the host. ``{start, end}`` ranges are left
out: a role allocates from a range, so one taken port in it does not
stop the role.
"""
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.preflight.context import deploy_closure_context
def _single_port(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value)
return None
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[dict[str, list[str]]]:
if not terms or len(terms) != 1:
raise AnsibleError(
"lookup('public_ports', group_names) expects exactly 1 term"
)
try:
context = deploy_closure_context(self, variables, terms[0])
except (TypeError, ValueError) as exc:
raise AnsibleError(f"public_ports: {exc}") from exc
ports: dict[str, list[str]] = {}
for role in context.roles:
services = (context.applications.get(role) or {}).get("services") or {}
for key, entry in sorted(services.items()):
if not isinstance(entry, dict):
continue
if not isinstance((entry.get("ports") or {}).get("public"), dict):
continue
public = context.config(role, f"services.{key}.ports.public", {}) or {}
for name, value in sorted(public.items()):
port = _single_port(value)
if port is not None:
ports.setdefault(str(port), []).append(f"{role} ({key}.{name})")
return [ports]
@@ -3,6 +3,7 @@ meshes: []
local:
subnet: 192.168.9.0/24
force_bridge: true
launcher: true
overlay:
modes: [swarm]
proxy_resolvable: true
@@ -73,8 +73,9 @@
- name: 🔧 set correct folder permissions
command:
cmd: "container run --rm --mount type=volume,src={{ MATRIX_SYNAPSE_VOLUME }},dst=/data -e SYNAPSE_SERVER_NAME={{ MATRIX_SYNAPSE_DOMAIN }} -e SYNAPSE_REPORT_STATS=no
--entrypoint /bin/sh {{ MATRIX_SYNAPSE_IMAGE }} -c 'chown -vR {{ MATRIX_SYNAPSE_UID }}:{{ MATRIX_SYNAPSE_GID }} /data'"
cmd: "container run --rm --mount type=volume,src={{ MATRIX_SYNAPSE_VOLUME }},dst=/data,volume-label=com.docker.compose.project={{ application_id | entity_name
}} -e SYNAPSE_SERVER_NAME={{ MATRIX_SYNAPSE_DOMAIN }} -e SYNAPSE_REPORT_STATS=no --entrypoint /bin/sh {{ MATRIX_SYNAPSE_IMAGE }} -c 'chown -vR {{ MATRIX_SYNAPSE_UID
}}:{{ MATRIX_SYNAPSE_GID }} /data'"
- name: ➕ add compose.yml
stack_host_template:
+3
View File
@@ -70,6 +70,9 @@
applications_carrier: {}
when: IS_SWARM_MODE
- name: "🛫 Pre-flight checks"
ansible.builtin.include_tasks: ./tasks/utils/constructor/preflight.yml
- name: "📦 Install the packages of every role this host may run, in one run"
package_install:
id: "{{ _package_batch_ids }}"
+32
View File
@@ -0,0 +1,32 @@
---
- name: "🔍 Check whether the container CLI is installed"
ansible.builtin.stat:
path: "{{ BIN_CONTAINER }}"
register: preflight_container_cli
- name: "🛫 Verify the kernel modules and the clock of a host outside a container"
ansible.builtin.include_tasks: preflight/kernel_clock.yml
when: not (DOCKER_IN_CONTAINER | bool)
- name: "🛫 Verify that the filesystems of the deploy have room left"
ansible.builtin.include_tasks: preflight/disk.yml
vars:
preflight_disk_paths: >-
{{ ['/'] + ([preflight_docker_root.stdout | from_json]
if preflight_container_cli.stat.exists else []) }}
- name: "🛫 Verify the docker objects and public ports where docker is installed"
when: preflight_container_cli.stat.exists
block:
- name: "🛫 Verify that every docker network belongs to its entity"
ansible.builtin.include_tasks: preflight/networks.yml
- name: "🛫 Verify the docker containers, volumes and address pools"
ansible.builtin.include_tasks: preflight/ownership.yml
- name: "🛫 Verify that no host process holds a public port of the deploy"
ansible.builtin.include_tasks: preflight/ports.yml
- name: "🛫 Verify that the clearnet domains of the deploy resolve in assert mode"
ansible.builtin.include_tasks: preflight/domains.yml
when: MODE_ASSERT | bool
@@ -0,0 +1,27 @@
---
- name: "🔍 Read the root directory of the docker daemon"
ansible.builtin.command:
argv: [container, info, --format, "{{ '{{json .DockerRootDir}}' }}"]
register: preflight_docker_root
changed_when: false
when: preflight_container_cli.stat.exists
- name: "🔍 Measure the usage of / and of the docker root directory"
ansible.builtin.command:
argv: "{{ ['df', '-P', '--'] + preflight_disk_paths }}"
register: preflight_df
changed_when: false
- name: "🛑 Fail when a filesystem of the deploy is fuller than its limit"
ansible.builtin.assert:
that: preflight_disk_errors | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_disk_errors | join('; ') }}.
Loki refuses log pushes above {{ SIZE_PERCENT_MAXIMUM_DISK_USAGE }} % and images fail to pull on a full disk.
Free space on each listed filesystem (prune unused images and volumes, rotate logs),
then deploy again.
vars:
preflight_disk_errors: >-
{{ preflight_df.stdout
| preflight_disk_usage(preflight_disk_paths, SIZE_PERCENT_MAXIMUM_DISK_USAGE) }}
@@ -0,0 +1,24 @@
---
# include-gated: when: MODE_ASSERT | bool
- name: "🔎 Resolve every clearnet domain of the deploy on the host"
ansible.builtin.command:
argv: [getent, hosts, "{{ preflight_domain }}"]
loop: "{{ lookup('deploy_domains', group_names) }}"
loop_control:
loop_var: preflight_domain
register: preflight_resolved
changed_when: false
failed_when: preflight_resolved.rc not in [0, 2]
- name: "🛑 Fail when a clearnet domain of the deploy does not resolve"
ansible.builtin.assert:
that: preflight_unresolved | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_unresolved | join(', ') }} resolve to no address on this host
(getent hosts). Add a DNS record (or an /etc/hosts entry) for each listed domain,
then deploy again.
vars:
preflight_unresolved: >-
{{ preflight_resolved.results | selectattr('rc', 'equalto', 2)
| map(attribute='preflight_domain') | list }}
@@ -0,0 +1,59 @@
---
# include-gated: when: not (DOCKER_IN_CONTAINER | bool)
- name: "🔍 Check whether the running kernel loads modules at all"
ansible.builtin.stat:
path: /proc/modules
register: preflight_proc_modules
- name: "🔍 List the kernels with an installed module index"
ansible.builtin.find:
paths:
- /usr/lib/modules
- /lib/modules
patterns: modules.dep
recurse: true
depth: 2
follow: true
register: preflight_module_indexes
- name: "🛑 Fail when the running kernel has no modules on disk"
ansible.builtin.assert:
that: >-
not preflight_proc_modules.stat.exists
or preflight_module_trees | length == 0
or ansible_facts['kernel'] in preflight_module_trees
quiet: true
fail_msg: >-
Pre-flight: reboot required: the running kernel {{ ansible_facts['kernel'] }} has no module index in
/usr/lib/modules or /lib/modules; only {{ preflight_module_trees | join(', ') }} have one.
No kernel module can load, so restarting Docker would fail to create its iptables chains
and leave every container down. Reboot into the installed kernel, then deploy again.
vars:
preflight_module_trees: >-
{{ preflight_module_indexes.files | map(attribute='path') | map('dirname')
| map('basename') | unique | sort }}
- name: "🔍 Check whether chrony keeps the host clock"
ansible.builtin.stat:
path: /usr/bin/chronyc
register: preflight_chronyc
- name: "⏳ Wait up to 2 min until chrony reports leap status Normal"
ansible.builtin.command:
argv: [chronyc, tracking]
register: preflight_chrony
changed_when: false
retries: 24
delay: 5
until: preflight_chrony.stdout is search('Leap status +[:] +Normal')
when: preflight_chronyc.stat.exists
- name: "⏳ Wait up to 2 min until timedatectl reports NTPSynchronized=yes"
ansible.builtin.command:
argv: [timedatectl, show, --property=NTPSynchronized, --value]
register: preflight_clock
changed_when: false
retries: 24
delay: 5
until: preflight_clock.stdout | trim == 'yes'
when: not preflight_chronyc.stat.exists
@@ -0,0 +1,24 @@
---
# include-gated: when: preflight_container_cli.stat.exists
- name: "🔍 List the docker networks"
ansible.builtin.command:
argv: [container, network, ls, --quiet]
register: preflight_network_ids
changed_when: false
- name: "🔍 Inspect the docker networks"
ansible.builtin.command:
argv: "{{ ['container', 'network', 'inspect'] + preflight_network_ids.stdout_lines }}"
register: preflight_networks
changed_when: false
- name: "🛑 Fail when a docker network does not belong to its entity"
ansible.builtin.assert:
that: preflight_network_errors | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_network_errors | join('; ') }}.
Stop the containers on each listed network and remove or re-address it,
then deploy again; the deploy recreates its own networks.
vars:
preflight_network_errors: "{{ lookup('network_ownership', preflight_networks, group_names) }}"
@@ -0,0 +1,71 @@
---
# include-gated: when: preflight_container_cli.stat.exists
- name: "🔍 List the docker containers with their project and stack labels"
ansible.builtin.command:
argv:
- container
- ps
- --all
- --format
- "{{ '{{.Names}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Label \"com.docker.stack.namespace\"}}' }}"
register: preflight_containers
changed_when: false
- name: "🛑 Fail when a declared container does not belong to its entity"
ansible.builtin.assert:
that: preflight_container_errors | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_container_errors | join('; ') }}.
Stop and remove each listed container (or rename it if it is not part of this deploy),
then deploy again; the deploy recreates its own containers.
vars:
preflight_container_errors: "{{ lookup('docker_ownership', 'container', preflight_containers, group_names) }}"
- name: "🔍 List the docker volumes with their project and stack labels"
ansible.builtin.command:
argv:
- container
- volume
- ls
- --format
- "{{ '{{.Name}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Label \"com.docker.stack.namespace\"}}' }}"
register: preflight_volumes
changed_when: false
- name: "🛑 Fail when a declared volume does not belong to its entity"
ansible.builtin.assert:
that: preflight_volume_errors | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_volume_errors | join('; ') }}.
Back up each listed volume, move its data into a volume of the named entity or remove it,
then deploy again; compose refuses a volume another project created.
vars:
preflight_volume_errors: "{{ lookup('docker_ownership', 'volume', preflight_volumes, group_names) }}"
- name: "🔍 Check whether the docker daemon has a config file"
ansible.builtin.stat:
path: /etc/docker/daemon.json
register: preflight_daemon_json_file
- name: "🔍 Read the docker daemon config"
ansible.builtin.slurp:
src: /etc/docker/daemon.json
register: preflight_daemon_json
when: preflight_daemon_json_file.stat.exists
- name: "🛑 Fail when a docker address pool overlaps a range of the deploy"
ansible.builtin.assert:
that: preflight_pool_errors | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_pool_errors | join('; ') }}.
Move default-address-pools in /etc/docker/daemon.json to {{ NETWORK_DOCKER_ADDRESS_POOLS | to_json }},
remove the networks docker addressed from the old pool and restart docker, then deploy again.
vars:
preflight_pool_errors: >-
{{ lookup('address_pool_overlaps',
(preflight_daemon_json.content | b64decode) if preflight_daemon_json_file.stat.exists else '{}',
group_names,
{'NETWORK_MESH_POOL': NETWORK_MESH_POOL, 'NETWORK_TOR_VIRTUAL_CIDR': NETWORK_TOR_VIRTUAL_CIDR}) }}
@@ -0,0 +1,34 @@
---
# include-gated: when: preflight_container_cli.stat.exists
- name: "🔍 List the listening sockets of the host with their processes"
ansible.builtin.command:
argv: [ss, -Htlnpu]
register: preflight_listeners
changed_when: false
- name: "🔍 Read the cgroup of every host process on a public port of the deploy"
ansible.builtin.command:
argv: "{{ ['grep', '-H', '.'] + preflight_cgroup_files }}"
register: preflight_cgroups
changed_when: false
failed_when: >-
preflight_cgroups.rc not in [0, 2]
or preflight_cgroups.stderr_lines | reject('search', ': No such file or directory$') | list | length > 0
when: preflight_cgroup_files | length > 0
vars:
preflight_cgroup_files: >-
{{ preflight_listeners.stdout
| preflight_port_cgroups(lookup('public_ports', group_names)) }}
- name: "🛑 Fail when a host process holds a public port of the deploy"
ansible.builtin.assert:
that: preflight_port_errors | length == 0
quiet: true
fail_msg: >-
Pre-flight: {{ preflight_port_errors | join('; ') }}.
Stop and disable each listed host service (or move it to another port),
then deploy again; the port is the deploy's to publish.
vars:
preflight_port_errors: >-
{{ preflight_listeners.stdout
| preflight_port_conflicts(preflight_cgroups, lookup('public_ports', group_names)) }}
@@ -0,0 +1,80 @@
import unittest
from ansible.errors import AnsibleFilterError
from plugins.filter.preflight import (
FilterModule,
preflight_disk_usage,
preflight_port_cgroups,
preflight_port_conflicts,
)
SS_LINES = (
'tcp LISTEN 0 100 0.0.0.0:25 0.0.0.0:* users:(("master",pid=812,fd=13))',
'tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=3001,fd=6))',
'tcp LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("docker-proxy",pid=7,fd=4))',
)
SS = "\n".join(SS_LINES)
PORTS = {
"25": ["web-app-mailu (mailu.smtp)"],
"80": ["svc-prx-openresty (openresty.http)"],
"443": ["svc-prx-openresty (openresty.https)"],
}
class TestPreflightFilters(unittest.TestCase):
def test_the_filters_are_registered(self):
self.assertEqual(
set(FilterModule().filters()),
{
"preflight_disk_usage",
"preflight_port_cgroups",
"preflight_port_conflicts",
},
)
def test_disk_usage_reads_df_against_the_limit(self):
output = "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda1 10 9 1 95% /\n"
self.assertEqual(
preflight_disk_usage(output, ["/"], "90"),
["/ lies on /, which is 95 % full (limit 90 %)"],
)
def test_unparsable_df_output_is_a_filter_error(self):
with self.assertRaises(AnsibleFilterError):
preflight_disk_usage("header only\n", ["/"], 90)
def test_the_cgroup_files_of_non_docker_listeners_are_listed(self):
self.assertEqual(
preflight_port_cgroups(SS, PORTS),
["/proc/812/cgroup", "/proc/3001/cgroup"],
)
def test_unparsable_ss_output_is_a_filter_error(self):
with self.assertRaises(AnsibleFilterError):
preflight_port_cgroups("garbage", PORTS)
with self.assertRaises(AnsibleFilterError):
preflight_port_conflicts("garbage", {"skipped": True}, PORTS)
def test_a_containerised_listener_passes_and_a_host_one_fails(self):
probe = {
"stdout": "/proc/812/cgroup:0::/system.slice/postfix.service\n"
"/proc/3001/cgroup:0::/system.slice/docker-0123456789abcdef.scope"
}
self.assertEqual(
preflight_port_conflicts(SS, probe, PORTS),
[
(
"master (pid 812) listens on tcp 0.0.0.0:25, "
"which web-app-mailu (mailu.smtp) binds"
)
],
)
def test_a_skipped_cgroup_probe_means_no_container(self):
ss = 'tcp LISTEN 0 1 0.0.0.0:443 0.0.0.0:* users:(("docker-proxy",pid=7,fd=4))'
self.assertEqual(preflight_port_conflicts(ss, {"skipped": True}, PORTS), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,94 @@
import json
import unittest
from unittest import mock
from ansible.errors import AnsibleError
from plugins.lookup.address_pool_overlaps import LookupModule
from utils.preflight.context import DeployClosureContext
MODULE = "plugins.lookup.address_pool_overlaps"
SUBNETS = {"web-app-matomo": "192.168.31.0/24"}
RESERVED = {"NETWORK_MESH_POOL": "10.100.0.0/16"}
def _context():
return DeployClosureContext(
roles=["sys-ctl-hlth-csp", "web-app-matomo"],
applications={},
config=lambda role, path, default: SUBNETS.get(role, default),
lookup=mock.Mock(),
)
class TestAddressPoolOverlapsLookup(unittest.TestCase):
def _run(self, terms, side_effect=None):
with (
mock.patch(
f"{MODULE}.deploy_closure_context",
return_value=_context(),
side_effect=side_effect,
),
mock.patch(
f"{MODULE}.entity_name", side_effect=lambda role: role.split("-", 2)[-1]
),
):
return LookupModule().run(terms, variables={})
@staticmethod
def _daemon(*bases):
return json.dumps(
{"default-address-pools": [{"base": base, "size": 24} for base in bases]}
)
def test_role_subnets_and_reserved_ranges_are_compared(self):
result = self._run(
[self._daemon("192.168.0.0/16", "10.0.0.0/8"), ["web-app-matomo"], RESERVED]
)
self.assertEqual(
result,
[
[
(
"docker default address pool 192.168.0.0/16 overlaps "
"192.168.31.0/24, which web-app-matomo declares for its "
"network 'matomo'"
),
(
"docker default address pool 10.0.0.0/8 overlaps 10.100.0.0/16, "
"which NETWORK_MESH_POOL reserves"
),
]
],
)
def test_a_clear_pool_and_an_absent_file_pass(self):
self.assertEqual(
self._run([self._daemon("10.208.0.0/12"), ["web-app-matomo"], RESERVED]),
[[]],
)
self.assertEqual(self._run(["{}", ["web-app-matomo"], RESERVED]), [[]])
def test_an_unparsable_daemon_json_is_rejected(self):
with self.assertRaises(AnsibleError):
self._run(["{not json", [], RESERVED])
def test_a_pool_without_base_is_rejected(self):
with self.assertRaises(AnsibleError):
self._run(['{"default-address-pools": [{"size": 24}]}', [], RESERVED])
def test_non_mapping_arguments_are_rejected(self):
with self.assertRaises(AnsibleError):
self._run(["[]", [], RESERVED])
def test_a_closure_error_becomes_an_ansible_error(self):
with self.assertRaises(AnsibleError):
self._run(["{}", [], RESERVED], side_effect=TypeError("x"))
def test_a_wrong_term_count_is_rejected(self):
with self.assertRaises(AnsibleError):
LookupModule().run(["{}", []])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,50 @@
import unittest
from unittest import mock
from ansible.errors import AnsibleError
from plugins.lookup.deploy_domains import LookupModule
from utils.preflight.context import DeployClosureContext
MODULE = "plugins.lookup.deploy_domains"
DOMAINS = {
"web-app-matomo": ["matomo.example.org", "matomo.abc.onion"],
"web-app-nextcloud": {"canonical": "cloud.example.org"},
}
def _context():
return DeployClosureContext(
roles=["svc-db-mariadb", "web-app-matomo", "web-app-nextcloud"],
applications={},
config=mock.Mock(),
lookup=lambda name, role, default: DOMAINS.get(role, default),
)
class TestDeployDomainsLookup(unittest.TestCase):
def _run(self, terms, side_effect=None):
with mock.patch(
f"{MODULE}.deploy_closure_context",
return_value=_context(),
side_effect=side_effect,
):
return LookupModule().run(terms, variables={})
def test_the_clearnet_domains_of_every_closure_role_are_listed(self):
self.assertEqual(
self._run([["web-app-matomo"]]),
[["cloud.example.org", "matomo.example.org"]],
)
def test_a_closure_error_becomes_an_ansible_error(self):
with self.assertRaises(AnsibleError):
self._run([[]], side_effect=TypeError("x"))
def test_a_wrong_term_count_is_rejected(self):
with self.assertRaises(AnsibleError):
LookupModule().run([[], []])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,127 @@
import unittest
from unittest import mock
from ansible.errors import AnsibleError
from plugins.lookup.docker_ownership import LookupModule
from utils.preflight.context import DeployClosureContext
MODULE = "plugins.lookup.docker_ownership"
APPLICATIONS = {
"web-app-matomo": {
"services": {
"matomo": {"name": "matomo", "image": "matomo"},
"mariadb": {"enabled": True, "shared": True},
}
},
"svc-db-mariadb": {"services": {"mariadb": {"name": "mariadb"}}},
"web-app-discourse": {"services": {"discourse": {"name": "discourse"}}},
}
LAUNCHERS = {"web-app-discourse"}
VOLUMES = {
"web-app-matomo": {
"data": {"type": "volume", "name": "matomo_data"},
"cache": {},
"php_hardening": {"type": "config", "source": "/etc/php.ini"},
},
}
def _config(role, path, default):
if path == "networks.local.launcher":
return role in LAUNCHERS
return APPLICATIONS[role]["services"][path.split(".")[1]]["name"]
def _context(roles=("svc-db-mariadb", "web-app-matomo", "web-app-discourse")):
return DeployClosureContext(
roles=list(roles),
applications=APPLICATIONS,
config=_config,
lookup=lambda name, role: VOLUMES.get(role, {}),
)
class TestDockerOwnershipLookup(unittest.TestCase):
def _run(self, terms, side_effect=None):
with (
mock.patch(
f"{MODULE}.deploy_closure_context",
return_value=_context(),
side_effect=side_effect,
) as context,
mock.patch(
f"{MODULE}.entity_name", side_effect=lambda role: role.split("-", 2)[-1]
),
):
result = LookupModule().run(terms, variables={})
return result, context
def test_containers_declared_by_name_are_checked(self):
probe = {"stdout": "matomo\tnextcloud\t\nmariadb\tmariadb\t\nlegacy\t\t"}
result, _context_mock = self._run(["container", probe, ["web-app-matomo"]])
self.assertEqual(
result,
[
[
(
"container 'matomo' belongs to compose project 'nextcloud', "
"but it is the container of web-app-matomo (entity 'matomo')"
)
]
],
)
def test_a_launcher_role_container_needs_no_compose_label(self):
probe = {"stdout": "discourse\t\t\nmatomo\tmatomo\t"}
result, _context_mock = self._run(["container", probe, ["web-app-discourse"]])
self.assertEqual(result, [[]])
def test_volumes_use_their_name_or_the_compose_default(self):
probe = {
"stdout": (
"matomo_data\tnextcloud\t\n"
"matomo_cache\tnextcloud\t\n"
"matomo_php_hardening\tnextcloud\t"
)
}
result, _context_mock = self._run(["volume", probe, ["web-app-matomo"]])
self.assertEqual(
result,
[
[
(
"volume 'matomo_data' belongs to compose project 'nextcloud', "
"but it is the volume of web-app-matomo (entity 'matomo')"
),
(
"volume 'matomo_cache' belongs to compose project 'nextcloud', "
"but it is the volume of web-app-matomo (entity 'matomo')"
),
]
],
)
def test_the_host_groups_reach_the_closure(self):
_result, context = self._run(["volume", {"stdout": ""}, ["web-app-matomo"]])
self.assertEqual(context.call_args.args[2], ["web-app-matomo"])
def test_an_unknown_kind_is_rejected(self):
with self.assertRaises(AnsibleError):
self._run(["network", {"stdout": ""}, []])
def test_an_unparsable_probe_is_rejected(self):
with self.assertRaises(AnsibleError):
self._run(["container", {"stdout": "only-a-name"}, []])
def test_a_closure_error_becomes_an_ansible_error(self):
with self.assertRaises(AnsibleError):
self._run(["container", {"stdout": ""}, []], side_effect=ValueError("x"))
def test_a_wrong_term_count_is_rejected(self):
with self.assertRaises(AnsibleError):
LookupModule().run(["container", {"stdout": ""}])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,99 @@
import json
import unittest
from unittest import mock
from ansible.errors import AnsibleError
from plugins.lookup.network_ownership import LookupModule
MODULE = "plugins.lookup.network_ownership"
SUBNETS = {"web-app-matomo": "192.168.31.0/24"}
def _config_run(terms, variables=None):
role, _path, default = terms
return [SUBNETS.get(role, default)]
class TestNetworkOwnershipLookup(unittest.TestCase):
def _run(self, terms, closure=None, side_effect=None):
applications = mock.Mock()
applications.run.return_value = [{"web-app-matomo": {}}]
config = mock.Mock()
config.run.side_effect = _config_run
with (
mock.patch(
f"{MODULE}.lookup_loader.get",
side_effect=lambda name, **kwargs: {
"applications": applications,
"config": config,
}[name],
),
mock.patch(f"{MODULE}.types_from_group_names", return_value=["server"]),
mock.patch(
f"{MODULE}.deploy_closure",
return_value=closure or {"web-app-matomo", "sys-ctl-hlth-csp"},
side_effect=side_effect,
) as closure_mock,
mock.patch(
f"{MODULE}.entity_name", side_effect=lambda role: role.split("-", 2)[-1]
),
):
result = LookupModule().run(terms, variables={"group_names": terms[-1]})
return result, closure_mock
@staticmethod
def _probe(*networks):
return {"rc": 0, "stdout": json.dumps(list(networks))}
def test_roles_with_a_subnet_are_checked_against_the_networks(self):
probe = self._probe(
{
"Name": "matomo",
"Labels": {"com.docker.compose.project": "matomo"},
"IPAM": {"Config": [{"Subnet": "192.168.40.0/24"}]},
}
)
result, _closure = self._run([probe, ["web-app-matomo"]])
self.assertEqual(
result,
[
[
"network 'matomo' holds 192.168.40.0/24, but web-app-matomo declares 192.168.31.0/24"
]
],
)
def test_matching_networks_return_no_errors(self):
probe = self._probe(
{
"Name": "matomo",
"Labels": {"com.docker.compose.project": "matomo"},
"IPAM": {"Config": [{"Subnet": "192.168.31.0/24"}]},
}
)
result, _closure = self._run([probe, ["web-app-matomo"]])
self.assertEqual(result, [[]])
def test_the_host_groups_seed_the_closure(self):
_result, closure = self._run([self._probe(), ["web-app-matomo", "servers"]])
self.assertEqual(closure.call_args.args[1], ["web-app-matomo", "servers"])
self.assertEqual(closure.call_args.kwargs["host_types"], ["server"])
def test_an_unparsable_probe_is_rejected(self):
with self.assertRaises(AnsibleError):
self._run([{"rc": 0, "stdout": "not json"}, ["web-app-matomo"]])
def test_a_closure_error_becomes_an_ansible_error(self):
with self.assertRaises(AnsibleError):
self._run(
[self._probe(), ["web-app-matomo"]], side_effect=ValueError("broken")
)
def test_a_wrong_term_count_is_rejected(self):
with self.assertRaises(AnsibleError):
LookupModule().run([self._probe()])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
import unittest
from unittest import mock
from ansible.errors import AnsibleError
from plugins.lookup.public_ports import LookupModule
from utils.preflight.context import DeployClosureContext
MODULE = "plugins.lookup.public_ports"
APPLICATIONS = {
"web-app-mailu": {
"services": {
"mailu": {"ports": {"public": {"smtp": 25, "imaps": "993"}}},
"redis": {"enabled": False},
}
},
"web-svc-coturn": {
"services": {
"coturn": {
"ports": {
"public": {
"stun_turn": 3478,
"relay": {"start": 20000, "end": 39999},
},
"local": {"http": 8064},
}
}
}
},
"web-app-other": {
"services": {"other": {"ports": {"public": {"smtp": 25, "flag": True}}}}
},
"sys-ctl-hlth-csp": {},
}
def _config(role, path, default):
key = path.split(".")[1]
return APPLICATIONS[role]["services"][key]["ports"]["public"]
def _context():
return DeployClosureContext(
roles=sorted(APPLICATIONS),
applications=APPLICATIONS,
config=_config,
lookup=mock.Mock(),
)
class TestPublicPortsLookup(unittest.TestCase):
def _run(self, terms, side_effect=None):
with mock.patch(
f"{MODULE}.deploy_closure_context",
return_value=_context(),
side_effect=side_effect,
):
return LookupModule().run(terms, variables={})
def test_single_ports_map_onto_every_role_that_binds_them(self):
self.assertEqual(
self._run([["web-app-mailu"]]),
[
{
"25": ["web-app-mailu (mailu.smtp)", "web-app-other (other.smtp)"],
"993": ["web-app-mailu (mailu.imaps)"],
"3478": ["web-svc-coturn (coturn.stun_turn)"],
}
],
)
def test_a_closure_error_becomes_an_ansible_error(self):
with self.assertRaises(AnsibleError):
self._run([[]], side_effect=ValueError("x"))
def test_a_wrong_term_count_is_rejected(self):
with self.assertRaises(AnsibleError):
LookupModule().run([])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,161 @@
import unittest
from utils.networks.ownership import ownership_errors
DECLARED = {
"matomo": [("web-app-matomo", "192.168.31.0/24")],
"postgres": [("svc-db-postgres", "192.168.200.0/24")],
}
SHARED = {
"litellm": [
("web-app-litellm", "192.168.76.0/24"),
("svc-ai-litellm", "192.168.80.0/24"),
],
}
def _network(name, *subnets, project=None):
labels = {} if project is None else {"com.docker.compose.project": project}
return {
"Name": name,
"Labels": labels,
"IPAM": {"Config": [{"Subnet": subnet} for subnet in subnets]},
}
class TestOwnershipErrors(unittest.TestCase):
def test_networks_that_match_their_owner_pass(self):
networks = [
_network("matomo", "192.168.31.0/24", project="matomo"),
_network("postgres", "192.168.200.0/24", project="postgres"),
]
self.assertEqual(ownership_errors(networks, DECLARED), [])
def test_a_network_inside_its_widened_block_passes(self):
networks = [_network("matomo", "192.168.31.0/28", project="matomo")]
self.assertEqual(ownership_errors(networks, DECLARED), [])
def test_a_network_reaching_beyond_its_block_is_drift(self):
networks = [_network("matomo", "192.168.30.0/23", project="matomo")]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
"network 'matomo' holds 192.168.30.0/23, but web-app-matomo declares 192.168.31.0/24"
],
)
def test_roles_sharing_an_entity_each_pass_with_their_subnet(self):
networks = [_network("litellm", "192.168.80.0/24", project="litellm")]
self.assertEqual(ownership_errors(networks, SHARED), [])
def test_roles_sharing_an_entity_name_every_accepted_subnet_on_drift(self):
networks = [_network("litellm", "192.168.90.0/24", project="litellm")]
self.assertEqual(
ownership_errors(networks, SHARED),
[
(
"network 'litellm' holds 192.168.90.0/24, but svc-ai-litellm "
"declares 192.168.80.0/24 or web-app-litellm declares 192.168.76.0/24"
)
],
)
def test_system_and_auto_addressed_networks_pass(self):
networks = [
_network("bridge", "172.17.0.0/16"),
{"Name": "host", "Labels": {}, "IPAM": {"Config": []}},
{"Name": "none", "Labels": None, "IPAM": {"Config": None}},
_network("matomo_default", "10.208.3.0/24", project="matomo"),
]
self.assertEqual(ownership_errors(networks, DECLARED), [])
def test_an_ipv6_subnet_next_to_the_declared_one_passes(self):
networks = [
_network("matomo", "192.168.31.0/24", "fd00:31::/64", project="matomo")
]
self.assertEqual(ownership_errors(networks, DECLARED), [])
def test_a_drifted_subnet_names_the_owner_and_both_subnets(self):
networks = [_network("matomo", "192.168.40.0/24", project="matomo")]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
"network 'matomo' holds 192.168.40.0/24, but web-app-matomo declares 192.168.31.0/24"
],
)
def test_a_network_without_a_subnet_is_drift(self):
networks = [
{"Name": "matomo", "Labels": {"com.docker.compose.project": "matomo"}}
]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
"network 'matomo' holds no subnet, but web-app-matomo declares 192.168.31.0/24"
],
)
def test_a_foreign_compose_project_is_reported(self):
networks = [_network("matomo", "192.168.31.0/24", project="nextcloud")]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
(
"network 'matomo' belongs to compose project 'nextcloud', "
"but it is the network of web-app-matomo"
)
],
)
def test_a_missing_project_label_is_reported(self):
networks = [_network("matomo", "192.168.31.0/24")]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
"network 'matomo' belongs to compose project '', but it is the network of web-app-matomo"
],
)
def test_a_foreign_network_on_a_declared_subnet_is_reported(self):
networks = [_network("legacy", "192.168.200.0/25")]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
(
"network 'legacy' occupies 192.168.200.0/25, which svc-db-postgres "
"declares for its network 'postgres'"
)
],
)
def test_a_secondary_network_of_the_owning_project_on_its_subnet_passes(self):
declared = {"bigbluebutton": [("web-app-bigbluebutton", "10.7.7.0/24")]}
networks = [
_network("bigbluebutton_bbb-net", "10.7.7.0/24", project="bigbluebutton")
]
self.assertEqual(ownership_errors(networks, declared), [])
def test_a_foreign_project_on_a_declared_subnet_is_still_reported(self):
networks = [_network("legacy_net", "192.168.200.0/25", project="legacy")]
self.assertEqual(len(ownership_errors(networks, DECLARED)), 1)
def test_an_owner_on_another_owners_subnet_reports_drift_and_clash(self):
networks = [_network("matomo", "192.168.200.0/24", project="matomo")]
self.assertEqual(
ownership_errors(networks, DECLARED),
[
"network 'matomo' holds 192.168.200.0/24, but web-app-matomo declares 192.168.31.0/24",
(
"network 'matomo' occupies 192.168.200.0/24, which svc-db-postgres "
"declares for its network 'postgres'"
),
],
)
def test_an_ipv6_subnet_never_clashes_with_an_ipv4_declaration(self):
networks = [_network("v6only", "::c0a8:1f00/120")]
self.assertEqual(ownership_errors(networks, DECLARED), [])
if __name__ == "__main__":
unittest.main()
Whitespace-only changes.
@@ -0,0 +1,65 @@
import unittest
from unittest import mock
from utils.preflight.context import deploy_closure_context
MODULE = "utils.preflight.context"
class TestDeployClosureContext(unittest.TestCase):
def _build(self, closure=None, side_effect=None):
plugins = {
"applications": mock.Mock(),
"config": mock.Mock(),
"domains": mock.Mock(),
}
plugins["applications"].run.return_value = [{"web-app-matomo": {}}]
plugins["config"].run.side_effect = lambda terms, variables=None: [
f"{terms[0]}:{terms[1]}"
]
plugins["domains"].run.return_value = [["matomo.example.org"]]
lookup = mock.Mock(_loader="loader", _templar=None)
with (
mock.patch(
f"{MODULE}.lookup_loader.get",
side_effect=lambda name, **kwargs: plugins[name],
),
mock.patch(f"{MODULE}.types_from_group_names", return_value=["server"]),
mock.patch(
f"{MODULE}.deploy_closure",
return_value=closure or {"web-app-matomo", "svc-db-mariadb"},
side_effect=side_effect,
) as closure_mock,
):
context = deploy_closure_context(
lookup, {"group_names": ["web-app-matomo"]}, ["web-app-matomo"]
)
readers = (
context.config("web-app-matomo", "services.matomo.name", ""),
context.lookup("domains", "web-app-matomo", []),
)
return context, readers, closure_mock
def test_the_closure_roles_come_sorted(self):
context, _readers, _closure = self._build()
self.assertEqual(context.roles, ["svc-db-mariadb", "web-app-matomo"])
self.assertEqual(context.applications, {"web-app-matomo": {}})
def test_the_host_groups_seed_the_closure(self):
_context, _readers, closure = self._build()
self.assertEqual(closure.call_args.args[1], ["web-app-matomo"])
self.assertEqual(closure.call_args.kwargs["host_types"], ["server"])
def test_the_readers_forward_to_their_lookups(self):
_context, readers, _closure = self._build()
self.assertEqual(
readers, ("web-app-matomo:services.matomo.name", ["matomo.example.org"])
)
def test_a_closure_error_propagates(self):
with self.assertRaises(ValueError):
self._build(side_effect=ValueError("broken"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,34 @@
import unittest
from utils.preflight.domains import clearnet_domains
class TestClearnetDomains(unittest.TestCase):
def test_mappings_lists_and_strings_flatten(self):
domains = [
{"canonical": ["matomo.example.org"], "api": "api.example.org"},
"example.org",
[],
]
self.assertEqual(
clearnet_domains(domains),
["api.example.org", "example.org", "matomo.example.org"],
)
def test_onion_addresses_are_left_out(self):
self.assertEqual(
clearnet_domains(["abc.onion", "x.abc.onion", "example.org"]),
["example.org"],
)
def test_case_trailing_dots_and_duplicates_collapse(self):
self.assertEqual(
clearnet_domains(["Example.org.", "example.org", " "]), ["example.org"]
)
def test_non_domain_values_are_ignored(self):
self.assertEqual(clearnet_domains([None, 5, True]), [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,56 @@
import unittest
from utils.preflight.host import disk_usage_errors
HEADER = "Filesystem 1024-blocks Used Available Capacity Mounted on"
def _df(*rows):
return "\n".join([HEADER, *rows]) + "\n"
class TestDiskUsageErrors(unittest.TestCase):
def test_filesystems_at_or_below_the_limit_pass(self):
output = _df(
"/dev/sda1 100 90 10 90% /",
"/dev/sdb1 100 12 88 12% /var/lib/docker",
)
self.assertEqual(
disk_usage_errors(["/", "/var/lib/docker"], output, 90),
[],
)
def test_a_filesystem_above_the_limit_names_path_mount_and_usage(self):
output = _df(
"/dev/sda1 100 50 50 50% /",
"/dev/sda1 100 91 9 91% /srv/docker data",
)
self.assertEqual(
disk_usage_errors(["/", "/srv/docker data/root"], output, 90),
[
"/srv/docker data/root lies on /srv/docker data, which is 91 % full (limit 90 %)"
],
)
def test_every_full_path_is_reported(self):
output = _df(
"overlay 100 95 5 95% /",
"overlay 100 95 5 95% /",
)
self.assertEqual(
len(disk_usage_errors(["/", "/var/lib/docker"], output, 90)), 2
)
def test_a_row_count_mismatch_is_rejected(self):
with self.assertRaises(ValueError):
disk_usage_errors(
["/", "/var/lib/docker"], _df("/dev/sda1 1 1 0 99% /"), 90
)
def test_a_row_without_a_percentage_is_rejected(self):
with self.assertRaises(ValueError):
disk_usage_errors(["/"], _df("/dev/sda1 1 1 0 - /"), 90)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,102 @@
import unittest
from utils.preflight.ownership import labelled_ownership_errors, parse_labelled
DECLARED = {
"matomo": [("web-app-matomo", "matomo")],
"matomo_data": [("web-app-matomo", "matomo")],
"redis": [("svc-db-redis", "redis"), ("web-app-nextcloud", "nextcloud")],
}
class TestParseLabelled(unittest.TestCase):
def test_lines_split_into_name_project_and_namespace(self):
output = "matomo\tmatomo\t\nmatomo_matomo.1.abc\t\tmatomo\n\n"
self.assertEqual(
parse_labelled(output),
[("matomo", "matomo", ""), ("matomo_matomo.1.abc", "", "matomo")],
)
def test_a_container_with_several_names_yields_each(self):
self.assertEqual(
parse_labelled("matomo,web/matomo\tmatomo\t"),
[("matomo", "matomo", ""), ("web/matomo", "matomo", "")],
)
def test_a_line_without_three_fields_is_rejected(self):
with self.assertRaises(ValueError):
parse_labelled("matomo matomo")
class TestLabelledOwnershipErrors(unittest.TestCase):
def test_objects_labelled_with_their_entity_pass(self):
objects = [
("matomo", "matomo", ""),
("matomo_data", "", "matomo"),
]
self.assertEqual(labelled_ownership_errors("volume", objects, DECLARED), [])
def test_undeclared_objects_pass_whatever_their_labels(self):
objects = [("legacy", "", ""), ("bridge-helper", "other", "")]
self.assertEqual(labelled_ownership_errors("container", objects, DECLARED), [])
def test_any_declaring_entity_is_accepted(self):
objects = [("redis", "nextcloud", "")]
self.assertEqual(labelled_ownership_errors("container", objects, DECLARED), [])
def test_a_foreign_project_names_the_owner(self):
objects = [("matomo", "nextcloud", "")]
self.assertEqual(
labelled_ownership_errors("container", objects, DECLARED),
[
(
"container 'matomo' belongs to compose project 'nextcloud', "
"but it is the container of web-app-matomo (entity 'matomo')"
)
],
)
def test_a_container_without_labels_fails(self):
objects = [("matomo", "", "")]
self.assertEqual(
labelled_ownership_errors("container", objects, DECLARED),
[
(
"container 'matomo' belongs to no compose project or stack, "
"but it is the container of web-app-matomo (entity 'matomo')"
)
],
)
def test_a_volume_without_any_label_passes(self):
objects = [("matomo_data", "", "")]
self.assertEqual(labelled_ownership_errors("volume", objects, DECLARED), [])
def test_a_volume_of_a_foreign_project_fails(self):
objects = [("matomo_data", "nextcloud", "")]
self.assertEqual(
labelled_ownership_errors("volume", objects, DECLARED),
[
(
"volume 'matomo_data' belongs to compose project 'nextcloud', "
"but it is the volume of web-app-matomo (entity 'matomo')"
)
],
)
def test_both_foreign_labels_and_every_owner_are_named(self):
objects = [("redis", "mailu", "mailu")]
self.assertEqual(
labelled_ownership_errors("container", objects, DECLARED),
[
(
"container 'redis' belongs to compose project 'mailu' and stack 'mailu', "
"but it is the container of svc-db-redis (entity 'redis') or "
"web-app-nextcloud (entity 'nextcloud')"
)
],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,56 @@
import unittest
from utils.preflight.pools import address_pool_errors
RESERVED = [
("NETWORK_MESH_POOL reserves", "10.100.0.0/16"),
("NETWORK_TOR_VIRTUAL_CIDR reserves", "10.240.0.0/12"),
("web-app-matomo declares for its network 'matomo'", "192.168.31.0/24"),
]
class TestAddressPoolErrors(unittest.TestCase):
def test_the_shipped_pool_is_clear(self):
config = {"default-address-pools": [{"base": "10.208.0.0/12", "size": 24}]}
self.assertEqual(address_pool_errors(config, RESERVED), [])
def test_an_absent_file_or_key_has_nothing_to_compare(self):
self.assertEqual(address_pool_errors({}, RESERVED), [])
self.assertEqual(address_pool_errors({"dns": ["192.0.2.53"]}, RESERVED), [])
def test_a_pool_over_a_role_subnet_and_the_mesh_names_both(self):
config = {
"default-address-pools": [
{"base": "192.168.0.0/16", "size": 20},
{"base": "10.0.0.0/8", "size": 24},
]
}
self.assertEqual(
address_pool_errors(config, RESERVED),
[
(
"docker default address pool 192.168.0.0/16 overlaps 192.168.31.0/24, "
"which web-app-matomo declares for its network 'matomo'"
),
(
"docker default address pool 10.0.0.0/8 overlaps 10.100.0.0/16, "
"which NETWORK_MESH_POOL reserves"
),
(
"docker default address pool 10.0.0.0/8 overlaps 10.240.0.0/12, "
"which NETWORK_TOR_VIRTUAL_CIDR reserves"
),
],
)
def test_an_ipv6_pool_never_overlaps_an_ipv4_range(self):
config = {"default-address-pools": [{"base": "fd00::/48", "size": 64}]}
self.assertEqual(address_pool_errors(config, RESERVED), [])
def test_a_pool_without_a_base_is_rejected(self):
with self.assertRaises(ValueError):
address_pool_errors({"default-address-pools": [{"size": 24}]}, RESERVED)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,119 @@
import unittest
from utils.preflight.ports import (
Listener,
containerised_pids,
parse_listeners,
port_conflicts,
probed_pids,
suspect_pids,
)
SS_LINES = (
'tcp LISTEN 0 100 0.0.0.0:25 0.0.0.0:* users:(("master",pid=812,fd=13))',
'tcp LISTEN 0 4096 [::]:443 [::]:* users:(("docker-proxy",pid=2001,fd=4))',
'tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=3001,fd=6),("nginx",pid=3002,fd=6))',
'udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=501,fd=13))',
"udp UNCONN 0 0 0.0.0.0:3478 0.0.0.0:*",
'tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=700,fd=3))',
)
SS = "\n".join(SS_LINES)
PORTS = {
25: ["web-app-mailu (mailu.smtp)"],
80: ["svc-prx-openresty (openresty.http)"],
443: ["svc-prx-openresty (openresty.https)"],
3478: ["web-svc-coturn (coturn.stun_turn)"],
}
CGROUP_LINES = (
"/proc/812/cgroup:0::/system.slice/postfix.service",
"/proc/3001/cgroup:0::/system.slice/docker-0123456789abcdef0123.scope",
"/proc/3002/cgroup:0::/docker/0123456789abcdef0123456789abcdef",
)
CGROUPS = "\n".join(CGROUP_LINES)
PROBED = {812, 3001, 3002}
class TestParseListeners(unittest.TestCase):
def test_lines_yield_protocol_address_port_and_processes(self):
listeners = parse_listeners(SS)
self.assertEqual(
listeners[1],
Listener("tcp", "[::]", 443, (("docker-proxy", 2001),)),
)
self.assertEqual(listeners[2].processes, (("nginx", 3001), ("nginx", 3002)))
self.assertEqual(listeners[3].address, "127.0.0.53%lo")
self.assertEqual(listeners[4].processes, ())
def test_an_ipv6_address_keeps_its_brackets(self):
self.assertEqual(parse_listeners(SS)[1].address, "[::]")
def test_an_unparsable_line_is_rejected(self):
with self.assertRaises(ValueError):
parse_listeners("tcp LISTEN 0 1 nonsense")
class TestSuspectPids(unittest.TestCase):
def test_only_non_docker_processes_on_deploy_ports_are_suspects(self):
self.assertEqual(suspect_pids(parse_listeners(SS), PORTS), [812, 3001, 3002])
class TestContainerisedPids(unittest.TestCase):
def test_docker_and_podman_cgroups_count_as_containers(self):
output = (
CGROUPS + "\n/proc/9/cgroup:0::/machine.slice/libpod-0123456789abcdef.scope"
)
self.assertEqual(containerised_pids(output), {3001, 3002, 9})
def test_the_docker_daemon_unit_is_no_container(self):
self.assertEqual(
containerised_pids("/proc/40/cgroup:0::/system.slice/docker.service"), set()
)
class TestPortConflicts(unittest.TestCase):
def test_host_processes_and_ownerless_sockets_conflict(self):
self.assertEqual(
port_conflicts(
parse_listeners(SS), containerised_pids(CGROUPS), PORTS, PROBED
),
[
"master (pid 812) listens on tcp 0.0.0.0:25, which web-app-mailu (mailu.smtp) binds",
(
"a socket without a process listens on udp 0.0.0.0:3478, "
"which web-svc-coturn (coturn.stun_turn) binds"
),
],
)
def test_a_host_networked_container_and_docker_proxy_pass(self):
listeners = parse_listeners(SS)[1:3]
self.assertEqual(
port_conflicts(listeners, containerised_pids(CGROUPS), PORTS, PROBED), []
)
def test_a_process_whose_cgroup_is_unknown_conflicts(self):
listeners = parse_listeners(SS)[2:3]
self.assertEqual(
port_conflicts(listeners, {3001}, PORTS, PROBED),
[
(
"nginx (pid 3002) listens on tcp 0.0.0.0:80, "
"which svc-prx-openresty (openresty.http) binds"
)
],
)
def test_a_process_that_exited_before_its_cgroup_was_read_passes(self):
listeners = parse_listeners(SS)[2:3]
self.assertEqual(port_conflicts(listeners, {3001}, PORTS, {3001}), [])
def test_probed_pids_lists_every_pid_with_a_cgroup_line(self):
self.assertEqual(probed_pids(CGROUPS), PROBED)
def test_ports_the_deploy_does_not_bind_pass(self):
listeners = [parse_listeners(SS)[5]]
self.assertEqual(port_conflicts(listeners, set(), PORTS, PROBED), [])
if __name__ == "__main__":
unittest.main()
+95
View File
@@ -0,0 +1,95 @@
"""Check that the docker networks on a host belong to the roles that own them:
named after the entity, labelled with it as compose project, holding a subnet
inside the declared block, and no network on a subnet another role declares.
"""
from __future__ import annotations
import ipaddress
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
PROJECT_LABEL = "com.docker.compose.project"
Network = ipaddress.IPv4Network | ipaddress.IPv6Network
def _subnets(network: Mapping[str, Any]) -> list[Network]:
configs = (network.get("IPAM") or {}).get("Config") or []
return [
ipaddress.ip_network(config["Subnet"], strict=False)
for config in configs
if config.get("Subnet")
]
def _within(subnets: list[Network], block: Network) -> bool:
held = [entry for entry in subnets if entry.version == block.version]
return bool(held) and all(entry.subnet_of(block) for entry in held)
def ownership_errors(
networks: Iterable[Mapping[str, Any]],
declared: Mapping[str, Iterable[tuple[str, str]]],
) -> list[str]:
"""Every way the host's networks contradict the roles that own them.
Args:
networks: ``docker network inspect`` objects of every network on the host.
declared: entity name -> every (role id, declared subnet) of the roles
the host deploys on that entity. Roles sharing an entity (a UI role
beside its service) each pass with their own subnet; a network
created before its role's block was widened passes while it stays
inside the block.
Returns:
One message per contradiction; empty when every network belongs to
its entity and no network occupies a subnet another role declares.
"""
owners = {
entity: [
(role, ipaddress.ip_network(subnet)) for role, subnet in sorted(claims)
]
for entity, claims in sorted(declared.items())
}
errors: list[str] = []
for network in networks:
name = str(network.get("Name", ""))
subnets = _subnets(network)
project = (network.get("Labels") or {}).get(PROJECT_LABEL, "")
if name in owners:
claims = owners[name]
if not any(_within(subnets, subnet) for _, subnet in claims):
versions = {subnet.version for _, subnet in claims}
listing = (
", ".join(
str(entry) for entry in subnets if entry.version in versions
)
or "no subnet"
)
expected = " or ".join(
f"{role} declares {subnet}" for role, subnet in claims
)
errors.append(f"network '{name}' holds {listing}, but {expected}")
if project != name:
errors.append(
f"network '{name}' belongs to compose project '{project}', "
f"but it is the network of {' or '.join(role for role, _ in claims)}"
)
for entity, claims in owners.items():
if entity in (name, project):
continue
for role, subnet in claims:
clash = [
str(entry)
for entry in subnets
if entry.version == subnet.version and entry.overlaps(subnet)
]
if clash:
errors.append(
f"network '{name}' occupies {', '.join(clash)}, which {role} "
f"declares for its network '{entity}'"
)
return errors
View File
Whitespace-only changes.
+76
View File
@@ -0,0 +1,76 @@
"""Shared Ansible-side assembly for the pre-flight lookup plugins.
Each of them checks the host against the roles the deploy may run on it, so
each needs the same three things: that role set, the merged applications the
set was derived from, and a reader for one role's rendered config. Building
them once keeps the lookups from drifting apart on which roles they check.
Args of :func:`deploy_closure_context` are documented on the function.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from ansible.plugins.loader import lookup_loader
from utils import PROJECT_ROOT
from utils.roles.invocations import deploy_closure
from utils.roles.validation.invokable import types_from_group_names
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
@dataclass(frozen=True)
class DeployClosureContext:
"""The roles a pre-flight lookup checks and the readers it needs for them."""
roles: list[str]
applications: dict[str, Any]
config: Callable[[str, str, Any], Any]
lookup: Callable[..., Any]
def deploy_closure_context(
lookup: Any, variables: dict[str, Any] | None, group_names: Iterable[Any]
) -> DeployClosureContext:
"""Assemble every role the deploy may run on the host.
Args:
lookup: the LookupModule instance, for its templar and loader.
variables: the variables Ansible passed to ``run``.
group_names: every group of the host, after the swarm auto-placement.
Returns:
The sorted roles of :func:`utils.roles.invocations.deploy_closure`, the
merged applications, ``config(role, path, default)`` over the
``config`` lookup and ``lookup(name, *terms)`` over any other one.
Raises:
TypeError, ValueError: the closure cannot be derived.
"""
groups = [str(group) for group in group_names]
templar = getattr(lookup, "_templar", None)
vars_ = variables or getattr(templar, "available_variables", {}) or {}
def _lookup(name: str, *terms: Any) -> Any:
return lookup_loader.get(name, loader=lookup._loader, templar=templar).run(
list(terms), variables=vars_
)[0]
applications = _lookup("applications")
roles = deploy_closure(
applications,
groups,
project_root=PROJECT_ROOT,
host_types=types_from_group_names(groups),
group_names=groups,
)
return DeployClosureContext(
roles=sorted(roles),
applications=applications,
config=lambda role, path, default: _lookup("config", role, path, default),
lookup=_lookup,
)
+34
View File
@@ -0,0 +1,34 @@
"""Collect the clearnet domains a deploy serves, for the DNS pre-flight."""
from __future__ import annotations
from typing import Any
ONION_SUFFIX = ".onion"
def _flatten(value: Any) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, dict):
return [domain for entry in value.values() for domain in _flatten(entry)]
if isinstance(value, (list, tuple, set)):
return [domain for entry in value for domain in _flatten(entry)]
return []
def clearnet_domains(domains: Any) -> list[str]:
"""The distinct clearnet domains of a canonical-domains subtree.
Args:
domains: one role's ``lookup('domains', role)`` value, a domain, a
list of them or a mapping onto them, or a list of such values.
Returns:
Every domain that is not a ``.onion`` address, lower-cased, without a
trailing dot, sorted and without duplicates.
"""
cleaned = {domain.strip().rstrip(".").lower() for domain in _flatten(domains)}
return sorted(
domain for domain in cleaned if domain and not domain.endswith(ONION_SUFFIX)
)
+42
View File
@@ -0,0 +1,42 @@
"""Read the host-state probes of the constructor pre-flight."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
def disk_usage_errors(paths: Sequence[str], df_output: str, maximum: int) -> list[str]:
"""Every probed path whose filesystem is fuller than the limit.
Args:
paths: the paths ``df -P`` was asked about, in the order it got them.
df_output: the stdout of ``df -P -- <paths>``, header included.
maximum: the highest usage in percent a filesystem may reach.
Returns:
One message per path whose filesystem uses more than ``maximum``
percent; empty when every filesystem stays at or below it.
Raises:
ValueError: the output holds another number of rows than paths, or a
row carries no usage percentage.
"""
rows = [line for line in df_output.splitlines()[1:] if line.strip()]
if len(rows) != len(paths):
raise ValueError(
f"df reported {len(rows)} filesystems for {len(paths)} paths: {df_output!r}"
)
errors: list[str] = []
for path, row in zip(paths, rows, strict=True):
fields = row.split(None, 5)
if len(fields) != 6 or not fields[4].rstrip("%").isdigit():
raise ValueError(f"df row without a usage percentage: {row!r}")
usage = int(fields[4].rstrip("%"))
if usage > maximum:
errors.append(
f"{path} lies on {fields[5]}, which is {usage} % full (limit {maximum} %)"
)
return errors
+88
View File
@@ -0,0 +1,88 @@
"""Check that the docker containers and volumes a deploy declares belong to
the entity of the role that declares them: labelled with it as compose
project, or as stack namespace in swarm.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
STACK_LABEL = "com.docker.stack.namespace"
def parse_labelled(output: str) -> list[tuple[str, str, str]]:
"""Split a ``<names>\\t<compose project>\\t<stack namespace>`` listing.
Args:
output: one line per docker object, as ``container ps --all`` or
``container volume ls`` print it with that ``--format``. A
container with several names lists them comma separated.
Returns:
One ``(name, compose project, stack namespace)`` per name; an absent
label is ``""``.
Raises:
ValueError: a line does not hold exactly three tab-separated fields.
"""
objects: list[tuple[str, str, str]] = []
for line in output.splitlines():
if not line.strip():
continue
fields = line.split("\t")
if len(fields) != 3:
raise ValueError(f"expected <names>\\t<project>\\t<namespace>: {line!r}")
names, project, namespace = (field.strip() for field in fields)
objects.extend(
(name, project, namespace) for name in names.split(",") if name.strip()
)
return objects
def labelled_ownership_errors(
kind: str,
objects: Iterable[tuple[str, str, str]],
declared: Mapping[str, Iterable[tuple[str, str]]],
) -> list[str]:
"""Every declared object on the host that does not carry its entity's label.
Args:
kind: ``container`` or ``volume``, for the messages.
objects: ``(name, compose project, stack namespace)`` of every object
on the host, as :func:`parse_labelled` returns them.
declared: object name -> every ``(role id, entity)`` of the roles the
host deploys that declare an object of that name. The object
passes when either label names any of these entities.
Returns:
One message per declared object whose labels name none of its
declaring entities; a container without labels fails, a volume
without any label passes, since compose adopts it; objects no role
declares pass.
"""
errors: list[str] = []
for name, project, namespace in objects:
claims = sorted(set(declared.get(name, ())))
if not claims:
continue
if kind == "volume" and not project and not namespace:
continue
if {project, namespace} & {entity for _, entity in claims} - {""}:
continue
owners = " or ".join(f"{role} (entity '{entity}')" for role, entity in claims)
held = " and ".join(
label
for label in (
f"compose project '{project}'" if project else "",
f"stack '{namespace}'" if namespace else "",
)
if label
)
errors.append(
f"{kind} '{name}' belongs to {held or 'no compose project or stack'}, "
f"but it is the {kind} of {owners}"
)
return errors
+48
View File
@@ -0,0 +1,48 @@
"""Check that the default address pools of the docker daemon stay clear of
every range the deploy addresses itself: the role subnets, the mesh pool and
the tor virtual range.
"""
from __future__ import annotations
import ipaddress
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
POOLS_KEY = "default-address-pools"
def address_pool_errors(
daemon_config: Mapping[str, Any], reserved: Iterable[tuple[str, str]]
) -> list[str]:
"""Every overlap of a daemon address pool with a reserved range.
Args:
daemon_config: the parsed ``/etc/docker/daemon.json``; ``{}`` when the
file is absent, which leaves nothing to compare.
reserved: ``(owner, CIDR)`` of every range no pool may overlap; the
owner completes the sentence ``..., which <owner>``.
Returns:
One message per overlapping pool and range; empty when every pool
stays clear of every reserved range.
Raises:
ValueError: a pool carries no ``base`` CIDR.
"""
ranges = [
(owner, ipaddress.ip_network(cidr, strict=False)) for owner, cidr in reserved
]
errors: list[str] = []
for pool in daemon_config.get(POOLS_KEY) or []:
if not isinstance(pool, dict) or not pool.get("base"):
raise ValueError(f"{POOLS_KEY} entry without a base: {pool!r}")
base = ipaddress.ip_network(str(pool["base"]), strict=False)
errors.extend(
f"docker default address pool {base} overlaps {network}, which {owner}"
for owner, network in ranges
if network.version == base.version and network.overlaps(base)
)
return errors
+170
View File
@@ -0,0 +1,170 @@
"""Find the host processes that already listen on a public port the deploy
binds. A socket of docker itself, or of a process inside a container (a
host-networked one such as the edge proxy), belongs to the deploy and passes.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Mapping
DOCKER_PROCESSES = frozenset({"docker-proxy", "dockerd"})
_PROCESS = re.compile(r'\("((?:[^"\\]|\\.)*)",pid=(\d+)')
_CGROUP_LINE = re.compile(r"^/proc/(\d+)/cgroup:(.*)$")
_CONTAINER_CGROUP = re.compile(r"/(?:docker|libpod)[-/][0-9a-f]{12,}")
@dataclass(frozen=True)
class Listener:
"""One listening socket of ``ss -Htlnpu``."""
protocol: str
address: str
port: int
processes: tuple[tuple[str, int], ...]
def parse_listeners(output: str) -> list[Listener]:
"""Split the output of ``ss -Htlnpu`` into its sockets.
Args:
output: the stdout of ``ss -Htlnpu``, run as root so every socket
names its processes.
Returns:
One :class:`Listener` per line; ``processes`` is empty for a socket
ss names no process for, such as one the kernel holds.
Raises:
ValueError: a line has no local ``address:port`` column.
"""
listeners: list[Listener] = []
for line in output.splitlines():
fields = line.split()
if not fields:
continue
if len(fields) < 6 or ":" not in fields[4]:
raise ValueError(f"unparsable ss line: {line!r}")
address, _, port = fields[4].rpartition(":")
if not port.isdigit():
raise ValueError(f"unparsable ss line: {line!r}")
processes = tuple(
(name, int(pid)) for name, pid in _PROCESS.findall(" ".join(fields[6:]))
)
listeners.append(Listener(fields[0], address, int(port), processes))
return listeners
def _held(
listeners: Iterable[Listener], ports: Mapping[int, object]
) -> Iterator[Listener]:
for listener in listeners:
if listener.port in ports:
yield listener
def suspect_pids(
listeners: Iterable[Listener], ports: Mapping[int, object]
) -> list[int]:
"""The processes on a deploy port whose cgroup decides whether they pass.
Args:
listeners: the sockets of the host, as :func:`parse_listeners` returns them.
ports: every public port the deploy binds, as keys.
Returns:
The sorted PIDs of every process other than docker's own that
listens on one of ``ports``.
"""
return sorted(
{
pid
for listener in _held(listeners, ports)
for name, pid in listener.processes
if name not in DOCKER_PROCESSES
}
)
def containerised_pids(output: str) -> set[int]:
"""The PIDs whose cgroup is a container's.
Args:
output: the stdout of ``grep -H . /proc/<pid>/cgroup ...``.
Returns:
Every PID with a cgroup line naming a docker or podman container.
"""
pids: set[int] = set()
for line in output.splitlines():
match = _CGROUP_LINE.match(line.strip())
if match and _CONTAINER_CGROUP.search(match.group(2)):
pids.add(int(match.group(1)))
return pids
def probed_pids(output: str) -> set[int]:
"""The PIDs that still ran when their cgroup was read.
Args:
output: the stdout of ``grep -H . /proc/<pid>/cgroup ...``.
Returns:
Every PID with at least one cgroup line.
"""
return {
int(match.group(1))
for line in output.splitlines()
if (match := _CGROUP_LINE.match(line.strip()))
}
def port_conflicts(
listeners: Iterable[Listener],
containerised: set[int],
ports: Mapping[int, Iterable[str]],
probed: set[int],
) -> list[str]:
"""Every deploy port a host process outside docker already listens on.
Args:
listeners: the sockets of the host, as :func:`parse_listeners` returns them.
containerised: the PIDs that run inside a container, as
:func:`containerised_pids` returns them.
ports: public port -> every ``<role> (<service>.<name>)`` that binds it.
probed: the PIDs whose cgroup could still be read, as
:func:`probed_pids` returns them. A PID ss listed that exited
before its cgroup was read no longer holds the port.
Returns:
One message per conflicting socket, naming the port, the protocol, the
address, the holding processes and the roles that bind the port. A
socket held only by docker or containerised processes passes; one
that names no process at all is a conflict, since nothing shows it
belongs to docker.
"""
errors: list[str] = []
for listener in _held(listeners, ports):
foreign = [
(name, pid)
for name, pid in listener.processes
if name not in DOCKER_PROCESSES
and pid in probed
and pid not in containerised
]
if listener.processes and not foreign:
continue
holders = (
", ".join(f"{name} (pid {pid})" for name, pid in foreign)
or "a socket without a process"
)
errors.append(
f"{holders} listens on {listener.protocol} {listener.address}:"
f"{listener.port}, which {' and '.join(sorted(ports[listener.port]))} binds"
)
return errors