mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The default is what varies between Docker versions, not the explicit value.
Measured by starting each engine and reading the mount back:
engine tmpfs without mode tmpfs with mode 1777
29.7.2 1777 root honoured
28.5.2 755 root 1777 root
A checkmk site runs as uid 1000, so a root-owned 755 tmp makes omdlib fail
`mkdir /omd/sites/cmk/tmp/apache` and the container restarts forever. That is
exactly what run 34348821577 shows, on runners reporting Docker 28.0.4.
f3eba39cae removed `mode: "01777"` here after measuring on 29.7.2 alone, where
the default happens to match, and so replaced the mechanism that behaves the
same everywhere with the one that does not. The mode comes back.
The validator loses the ban with it. Its stated reason, that `docker stack
deploy` drops the key, is a different claim from the one the removing commit
made, and neither survives the measurement above for the compose path this
failure came from; it now checks the type and nothing more.
One thing this does not settle. The note this repository kept on the earlier
attempt records run 33923190220 failing the same way on 28.0.4 while the mode
WAS set. That contradicts the 28.5.2 row and did not reproduce, so either the
patch releases differ or that diagnosis was wrong. The next run decides it:
read the engine version out of the rescue artifact and check whether the site
tmp comes up 1777.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Normalize and validate the role-level mount declaration in
|
|
``roles/<role>/meta/volumes.yml``.
|
|
|
|
Canonical shape (dict-of-dicts; the YAML key is the semantic short name):
|
|
|
|
data: # YAML key = semantic short name
|
|
type: volume # bind | volume | config | secret | tmpfs
|
|
name: matrix_synapse_data # OPTIONAL docker volume name; defaults to the key
|
|
nfs: true # only meaningful for type: volume
|
|
mounts:
|
|
- service: synapse
|
|
target: /data
|
|
read_only: false # per-mount override of volume-level
|
|
when: "{{ FLAG | bool }}"
|
|
|
|
config:
|
|
type: bind # bind | config | secret may carry source
|
|
source: "{{ FOO }}"
|
|
read_only: true
|
|
mode: "0440" # only for config/secret
|
|
mounts:
|
|
- service: synapse
|
|
target: /etc/synapse/config
|
|
|
|
scratch:
|
|
type: tmpfs
|
|
mounts:
|
|
- service: synapse
|
|
target: /var/run/synapse
|
|
size: 64m # per-mount tmpfs size
|
|
|
|
The legacy list-of-dicts shape is no longer accepted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterable
|
|
|
|
_VALID_TYPES = frozenset({"bind", "volume", "config", "secret", "tmpfs"})
|
|
_TYPE_REQUIRES_SOURCE = frozenset({"bind", "config", "secret"})
|
|
_TYPE_HAS_MODE = frozenset({"config", "secret"})
|
|
_TYPE_HAS_READ_ONLY = frozenset({"bind", "volume"})
|
|
_TYPE_HAS_NFS = frozenset({"volume"})
|
|
_TYPE_HAS_BACKUP = frozenset({"volume"})
|
|
|
|
|
|
class VolumesSchemaError(ValueError):
|
|
"""Raised when a meta/volumes.yml fails schema validation."""
|
|
|
|
|
|
def _is_octal_mode(value: Any) -> bool:
|
|
"""True when ``value`` is a leading-zero octal permission string."""
|
|
return (
|
|
isinstance(value, str) and value.startswith("0") and value.lstrip("0").isdigit()
|
|
)
|
|
|
|
|
|
def normalize_volumes_meta(
|
|
meta_volumes: Any,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Return a dict[semantic_name -> canonical entry].
|
|
|
|
The YAML key is the semantic short name. Each entry value MAY carry
|
|
``name`` (the docker volume name); when absent and ``type`` is
|
|
``volume``, callers default the docker name to the semantic key.
|
|
"""
|
|
if meta_volumes is None:
|
|
return {}
|
|
if isinstance(meta_volumes, list):
|
|
raise VolumesSchemaError(
|
|
"list-shape meta/volumes.yml is no longer supported; use dict-keyed-by-name"
|
|
)
|
|
if not isinstance(meta_volumes, dict):
|
|
raise VolumesSchemaError(
|
|
f"meta/volumes.yml must be a dict, got {type(meta_volumes).__name__}"
|
|
)
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for semantic_name, raw_spec in meta_volumes.items():
|
|
if not isinstance(semantic_name, str) or not semantic_name.strip():
|
|
raise VolumesSchemaError(
|
|
f"meta/volumes.yml key must be a non-empty string, got {semantic_name!r}"
|
|
)
|
|
if not isinstance(raw_spec, dict):
|
|
raise VolumesSchemaError(
|
|
f"volume entry for {semantic_name!r} must be a dict, "
|
|
f"got {type(raw_spec).__name__}"
|
|
)
|
|
entry = dict(raw_spec)
|
|
entry.setdefault("type", "volume")
|
|
out[semantic_name] = entry
|
|
return out
|
|
|
|
|
|
def validate_volumes_meta(
|
|
meta_volumes: Any,
|
|
role_id: str,
|
|
) -> list[str]:
|
|
"""Return a list of human-readable violation strings (empty = valid)."""
|
|
violations: list[str] = []
|
|
try:
|
|
entries = normalize_volumes_meta(meta_volumes)
|
|
except VolumesSchemaError as exc:
|
|
return [f"{role_id}: {exc}"]
|
|
|
|
for semantic_name, entry in entries.items():
|
|
prefix = f"{role_id} ({semantic_name})"
|
|
|
|
if "name" in entry:
|
|
docker_name = entry["name"]
|
|
if not isinstance(docker_name, str) or not docker_name.strip():
|
|
violations.append(
|
|
f"{prefix}: 'name' (container volume name) must be a non-empty string"
|
|
)
|
|
|
|
vtype = entry.get("type", "volume")
|
|
if vtype not in _VALID_TYPES:
|
|
violations.append(
|
|
f"{prefix}: 'type' must be one of {sorted(_VALID_TYPES)}, got {vtype!r}"
|
|
)
|
|
continue
|
|
|
|
if vtype in _TYPE_REQUIRES_SOURCE and not entry.get("source"):
|
|
violations.append(f"{prefix}: type '{vtype}' requires a non-empty 'source'")
|
|
if vtype not in _TYPE_REQUIRES_SOURCE and entry.get("source"):
|
|
violations.append(
|
|
f"{prefix}: type '{vtype}' MUST NOT carry 'source' (got {entry['source']!r})"
|
|
)
|
|
|
|
if "mode" in entry and vtype not in _TYPE_HAS_MODE:
|
|
violations.append(
|
|
f"{prefix}: 'mode' is only valid for type config/secret (got type={vtype!r})"
|
|
)
|
|
if (
|
|
"mode" in entry
|
|
and vtype in _TYPE_HAS_MODE
|
|
and not _is_octal_mode(entry["mode"])
|
|
):
|
|
violations.append(
|
|
f"{prefix}: 'mode' must be an octal string like \"0440\", "
|
|
f"got {entry['mode']!r}"
|
|
)
|
|
|
|
if "read_only" in entry and vtype not in _TYPE_HAS_READ_ONLY:
|
|
violations.append(
|
|
f"{prefix}: 'read_only' is only valid for type bind/volume (got type={vtype!r})"
|
|
)
|
|
if "read_only" in entry and not isinstance(entry["read_only"], bool):
|
|
violations.append(
|
|
f"{prefix}: 'read_only' must be bool, got {type(entry['read_only']).__name__}"
|
|
)
|
|
|
|
if "nfs" in entry and vtype not in _TYPE_HAS_NFS:
|
|
violations.append(
|
|
f"{prefix}: 'nfs' is only valid for type volume (got type={vtype!r})"
|
|
)
|
|
if "nfs" in entry and not isinstance(entry["nfs"], (bool, dict)):
|
|
violations.append(
|
|
f"{prefix}: 'nfs' must be bool or dict, got {type(entry['nfs']).__name__}"
|
|
)
|
|
|
|
if "backup" in entry and vtype not in _TYPE_HAS_BACKUP:
|
|
violations.append(
|
|
f"{prefix}: 'backup' is only valid for type volume (got type={vtype!r})"
|
|
)
|
|
if "backup" in entry and not isinstance(entry["backup"], bool):
|
|
violations.append(
|
|
f"{prefix}: 'backup' must be bool, got {type(entry['backup']).__name__}"
|
|
)
|
|
|
|
if "swarm_safe" in entry and vtype != "bind":
|
|
violations.append(
|
|
f"{prefix}: 'swarm_safe' opt-out is only meaningful for type bind "
|
|
f"(got type={vtype!r})"
|
|
)
|
|
if "swarm_safe" in entry and not isinstance(entry["swarm_safe"], bool):
|
|
violations.append(
|
|
f"{prefix}: 'swarm_safe' must be bool, got "
|
|
f"{type(entry['swarm_safe']).__name__}"
|
|
)
|
|
|
|
if vtype == "tmpfs":
|
|
violations.extend(
|
|
f"{prefix}: tmpfs 'size' must be a string or int, got "
|
|
f"{type(mount['size']).__name__}"
|
|
for mount in (entry.get("mounts") or [])
|
|
if "size" in mount and not isinstance(mount["size"], (str, int))
|
|
)
|
|
violations.extend(
|
|
f"{prefix}: tmpfs 'mode' must be an octal string or int, got "
|
|
f"{type(mount['mode']).__name__}"
|
|
for mount in (entry.get("mounts") or [])
|
|
if "mode" in mount and not isinstance(mount["mode"], (str, int))
|
|
)
|
|
|
|
mounts = entry.get("mounts")
|
|
if mounts is None:
|
|
continue
|
|
if not isinstance(mounts, list):
|
|
violations.append(
|
|
f"{prefix}: 'mounts' must be a list, got {type(mounts).__name__}"
|
|
)
|
|
continue
|
|
for midx, mount in enumerate(mounts):
|
|
mprefix = f"{prefix}.mounts[{midx}]"
|
|
if not isinstance(mount, dict):
|
|
violations.append(f"{mprefix}: mount entry must be a dict")
|
|
continue
|
|
if (
|
|
not isinstance(mount.get("service"), str)
|
|
or not mount["service"].strip()
|
|
):
|
|
violations.append(
|
|
f"{mprefix}: 'service' is required and must be a non-empty string"
|
|
)
|
|
if not isinstance(mount.get("target"), str) or not mount["target"].strip():
|
|
violations.append(
|
|
f"{mprefix}: 'target' is required and must be a non-empty string"
|
|
)
|
|
if "read_only" in mount and vtype not in _TYPE_HAS_READ_ONLY:
|
|
violations.append(
|
|
f"{mprefix}: 'read_only' is only valid for type bind/volume (got type={vtype!r})"
|
|
)
|
|
if "read_only" in mount and not isinstance(mount["read_only"], bool):
|
|
violations.append(
|
|
f"{mprefix}: 'read_only' must be bool, got {type(mount['read_only']).__name__}"
|
|
)
|
|
|
|
return violations
|
|
|
|
|
|
def mount_default_read_only(volume: dict[str, Any]) -> bool:
|
|
"""Default read-only policy per type.
|
|
|
|
bind/volume default to rw (False); config/secret are conceptually
|
|
read-only at the docker primitive level; tmpfs is rw.
|
|
"""
|
|
return volume.get("type", "volume") in _TYPE_HAS_MODE
|
|
|
|
|
|
def content_hash(text: str, length: int = 8) -> str:
|
|
"""Stable short hex digest of a string (used for swarm config/secret name rotation)."""
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:length]
|
|
|
|
|
|
def mounts_for_service(
|
|
volume: dict[str, Any], service: str
|
|
) -> Iterable[dict[str, Any]]:
|
|
"""Yield mount dicts in ``volume['mounts']`` that target ``service``."""
|
|
for mount in volume.get("mounts") or []:
|
|
if isinstance(mount, dict) and mount.get("service") == service:
|
|
yield mount
|
|
|
|
|
|
def mount_when_passes(
|
|
mount: dict[str, Any], render_jinja: Callable[[str], str] | None
|
|
) -> bool:
|
|
"""Evaluate the optional ``when:`` gate of a single mount entry.
|
|
|
|
Args:
|
|
mount: one dict from ``volume['mounts']``.
|
|
render_jinja: renders a Jinja expression string; None disables rendering.
|
|
|
|
Returns:
|
|
True when the mount is active.
|
|
"""
|
|
when = mount.get("when")
|
|
if when is None:
|
|
return True
|
|
if isinstance(when, bool):
|
|
return when
|
|
text = str(when).strip()
|
|
if not text:
|
|
return True
|
|
if "{{" not in text and "{%" not in text:
|
|
return text.lower() not in {"false", "no", "0", "off"}
|
|
if render_jinja is None:
|
|
return False
|
|
try:
|
|
rendered = render_jinja(text)
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
if isinstance(rendered, bool):
|
|
return rendered
|
|
return str(rendered).strip().lower() not in {"false", "no", "0", "off", ""}
|