Files
computer-playbook/utils/storage/nfs.py
T
kevinveenbirkenbachandClaude Opus 5 2e1057e4cd fix(bkp-volume): capture an NFS-backed volume once, not twice
Since the swarm-NFS migration two chains copied the same bytes: svc-bkp-nfs-2-local
reads the export from the NFS server's own filesystem, while baudolo on the manager
copied the same files again THROUGH the NFS mount. That was a migration leftover -
svc-bkp-nfs-2-local arrived in adb8bcf69 (2026-07-10) and nobody taught the older role
to step back - and the role README already forbade it as a rule nobody enforced. In the
DR drill the duplicate costs three times over: the pulled tree, the device copy and the
restore root.

swarm_nfs_backed() in utils/storage/nfs.py is now the single answer to "does the swarm
NFS rewrite back this volume". plugins/filter/compose_volumes.py (which performs the
rewrite) and this role's backup_volume lookup (which feeds baudolo's
--volumes-no-backup-required) both call it, so the two cannot drift. On the current tree
that excludes 83 of 158 type: volume entries.

Four conditions keep the exclusion from ever dropping a volume nobody else captures:

  * a manager-pinned role's volumes are not on the export (web-app-seaweedfs holds
    Nextcloud's object store, deliberately node-local per its meta/services.yml)
  * a role forcing another mode via compose_mode_force is not on the export either.
    That override is per role (roles/sys-svc-compose/defaults/main.yml:3), so it is
    resolved per role inside the predicate from roles/<id>/vars/main.yml, not read once
    from ambient scope. web-app-bigbluebutton pins compose statically;
    web-app-matrix computes it, and a value the predicate cannot resolve counts as
    forcing - unknown mode keeps the volume backed up
  * an entry opting out with nfs: false stays node-local
  * the exclusion applies at all only when the svc-bkp-nfs-2-local inventory group is
    populated. That role enters a play by group membership alone
    (tasks/groups/svc-bkp-roles.yml:19-22) and svc-storage-nfs-server ships a variant
    with nfs_backup: {enabled: false}, so without the gate an export whose repository
    nobody deployed would lose 83 volumes from both capture paths

Every condition fails toward the old double capture: wasted space, never lost data.
Compose mode is untouched - the exclusion cannot fire without swarm and an nfs backend.

DEPLOYMENT_MODE and storage are templated through the lookup's templar, because
group_vars/all/18_swarm.yml:2 defines the mode as a Jinja expression; comparing the raw
source string would silently disable the whole exclusion.

Twelve new tests pin the contract, including the two failure modes that would be
invisible in production: an untemplated mode, and an exclusion without an export
repository. Both were verified by mutation - removing either guard makes its test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:46 +02:00

130 lines
4.3 KiB
Python

from __future__ import annotations
import re
from collections.abc import Mapping
from pathlib import PurePosixPath
from utils import PROJECT_ROOT
from utils.cache.files import read_text
from utils.roles.mapping import ROLE_FILE_META_SERVICES
STATE_SUBDIR = "infinito-state"
NFS_SERVER_SERVICES_YML = str(
PROJECT_ROOT / "roles" / "svc-storage-nfs-server" / ROLE_FILE_META_SERVICES
)
NFS_CLIENT_SERVICES_YML = str(
PROJECT_ROOT / "roles" / "svc-storage-nfs-client" / ROLE_FILE_META_SERVICES
)
def _read_spot_value(services_yml: str, key: str) -> str:
"""Read a scalar entity value via line parse.
Exception: stdlib-only on purpose - these SPOTs feed the .env
generation, which bootstraps fresh hosts before PyYAML exists.
Args:
services_yml: repo-relative services.yml path.
key: two-space-indented scalar key to read.
"""
pattern = re.compile(rf"^ {re.escape(key)}:\s*(\S+)\s*$")
for line in read_text(services_yml).splitlines():
match = pattern.match(line)
if match:
return match.group(1)
raise KeyError(f"{key} not found in {services_yml}")
def get_export_base() -> str:
"""NFS export base from the provider's services.yml SPOT."""
return _read_spot_value(NFS_SERVER_SERVICES_YML, "export_base")
def get_client_version() -> int:
"""NFS mount protocol version from the client's services.yml SPOT."""
return int(_read_spot_value(NFS_CLIENT_SERVICES_YML, "nfs_version"))
def state_path(export_base, subdir):
return str(PurePosixPath(str(export_base)) / str(subdir))
def fstype(version):
return "nfs4" if int(version) >= 4 else "nfs"
def mount_opts(version, runtime):
reliability = (
"soft,timeo=50,retrans=3"
if runtime in ("dev", "act", "github")
else "hard,timeo=600"
)
locking = "" if int(version) >= 4 else ",nolock"
return f"vers={version},rw,{reliability}{locking}"
def client_src(server, version, flavor, state_path_value):
use_root = flavor == "kernel" and int(version) >= 4
return f"{server}:{'/' if use_root else state_path_value}"
def _role_forces_other_mode(application_id):
"""True unless the role certainly runs at the cluster's deployment mode.
``compose_mode_force`` overrides the cluster mode for one role only
(roles/sys-svc-compose/defaults/main.yml). A value this function cannot
resolve statically - a Jinja expression - counts as forcing, so an
unknown mode keeps a volume backed up rather than silently dropping it
from both capture paths.
Args:
application_id: role whose ``vars/main.yml`` is read.
"""
from utils import PROJECT_ROOT
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_VARS_MAIN
path = PROJECT_ROOT / "roles" / str(application_id) / ROLE_FILE_VARS_MAIN
if not path.is_file():
return False
loaded = load_yaml_any(str(path))
if not isinstance(loaded, Mapping):
return False
forced = loaded.get("compose_mode_force")
if forced is None:
return False
forced = str(forced).strip()
if not forced:
return False
if "{{" in forced or "{%" in forced:
return True
return forced.lower() != "swarm"
def swarm_nfs_backed(entry, *, application_id, deployment_mode, storage_backend):
"""True when the swarm NFS rewrite backs this ``meta/volumes.yml`` entry.
Args:
entry: volume entry mapping; ``nfs: false`` opts it out of the rewrite.
application_id: role the volume belongs to; a manager-pinned role or one
forcing a non-swarm mode keeps its volumes node-local.
deployment_mode: cluster-wide ``compose`` or ``swarm``.
storage_backend: the ``storage.backend`` value, e.g. ``local`` or ``nfs``.
"""
if str(deployment_mode).strip().lower() != "swarm":
return False
if str(storage_backend).strip().lower() != "nfs":
return False
if not isinstance(entry, Mapping):
return False
if entry.get("nfs") is False:
return False
# Exception: imported lazily because this module stays import-safe for the
# .env bootstrap, which runs before PyYAML exists.
from utils.roles.meta_lookup import get_role_placement
if str(get_role_placement(application_id) or "").strip().lower() == "manager":
return False
return not _role_forces_other_mode(application_id)