mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 03:15:56 +00:00
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>
130 lines
4.3 KiB
Python
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)
|