mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-12 13:47:01 +00:00
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:
1 parent
9ad3fe32d9
commit
a67dd9b372
39 files changed
+2400
-2
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
)
|
||||
]
|
||||
@@ -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)]
|
||||
@@ -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)]
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user