diff --git a/plugins/lookup/compose_volumes.py b/plugins/lookup/compose_volumes.py index 03bfbd3f40..8eaec50e08 100644 --- a/plugins/lookup/compose_volumes.py +++ b/plugins/lookup/compose_volumes.py @@ -32,7 +32,7 @@ from utils.roles.applications.services.database import ( resolve_database_service_key, ) from utils.roles.applications.services.sso import get_sso_config -from utils.storage.nfs import swarm_nfs_backed +from utils.storage.nfs import swarm_nfs_backed, volume_dir from utils.templating.ansible import _trust_as_template, to_plain @@ -267,8 +267,9 @@ def compose_volumes( deployment_mode=deployment_mode, storage_backend=storage_backend, ): - named = vol_spec.get("name", vol_name) - vol_spec.update(_swarm_nfs_driver_opts(dir_var_lib, str(named))) + vol_spec.update( + _swarm_nfs_driver_opts(dir_var_lib, volume_dir(vol_name, vol_spec)) + ) if isinstance(nfs_meta, dict): vol_spec["x-infinito-nfs"] = dict(nfs_meta) diff --git a/roles/svc-bkp-nfs-2-local/README.md b/roles/svc-bkp-nfs-2-local/README.md index 9a50ea768b..32b8785e96 100644 --- a/roles/svc-bkp-nfs-2-local/README.md +++ b/roles/svc-bkp-nfs-2-local/README.md @@ -18,9 +18,9 @@ flowchart TD TIMER["systemd timer
SYS_SCHEDULE_BACKUP_NFS_TO_LOCAL (01:30)"] --> UNIT PRELOAD["sys-service-loader preload
MODE_BACKUP, run_after svc-storage-nfs-server
(force_flush_instant + state started)"] --> UNIT UNIT["svc-bkp-nfs-2-local.<version>.<domain>.service"] --> LOCK["ExecStartPre: sys-lock against the manipulation group"] - LOCK --> SCRIPT["ExecStart: script.sh <export_base> <backups_dir>
backup-nfs-to-local <state>/backup"] + LOCK --> SCRIPT["ExecStart: script.sh <export_base> <backups_dir>
backup-nfs-to-local <state>/backup <state>/<backup-disabled volume>..."] SCRIPT -->|export_base missing| ALARM["ERROR exit 1
OnFailure alarm"] - SCRIPT --> RSYNC["rsync -aHAX --delete
--exclude /<state>/backup (no backups-in-backups)
--link-dest previous generation (hard links)"] + SCRIPT --> RSYNC["rsync -aHAX --delete
--exclude /<state>/backup (no backups-in-backups)
--exclude /<state>/<volume> per backup: false volume
--link-dest previous generation (hard links)"] RSYNC -->|exit 24: live export churn| WARN["WARN, snapshot kept"] RSYNC -->|any other failure| CLEAN["generation removed, exit != 0"] RSYNC --> TREE["<backups_dir>/<sha256(machine-id)>/
backup-nfs-to-local/<YYYYmmddHHMMSS>/files/..."] @@ -30,6 +30,7 @@ flowchart TD ## Features - **Differential snapshots:** rsync `--link-dest` against the previous generation deduplicates unchanged files. +- **Backup-disabled volumes skipped:** the directory of every NFS-backed volume declared `backup: false` in its role's `meta/volumes.yml` is excluded, the same declaration [svc-bkp-volume-2-local](../svc-bkp-volume-2-local/) honours; a directory a backed-up volume also binds to stays in the snapshot. - **Baudolo-compatible layout:** snapshots land in the same `//` tree as the container backups, so pull and cleanup tooling applies unchanged. - **Schedule-coordinated:** the systemd unit is part of the global manipulation group, so it never races backup/cleanup/repair jobs on the same host. diff --git a/roles/svc-bkp-nfs-2-local/files/python/recover.py b/roles/svc-bkp-nfs-2-local/files/python/recover.py index a2b0f7ebfe..167b2d33ba 100755 --- a/roles/svc-bkp-nfs-2-local/files/python/recover.py +++ b/roles/svc-bkp-nfs-2-local/files/python/recover.py @@ -23,16 +23,42 @@ from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[4] # nocheck: project-root-import sys.path.insert(0, str(_REPO_ROOT)) -from utils.recovery.base import DirectoryRecovery # noqa: E402 +from utils.recovery.base import DirectoryRecovery, is_remote # noqa: E402 from utils.storage.nfs import STATE_SUBDIR # noqa: E402 class NfsExportRecovery(DirectoryRecovery): unit_pattern = "svc-bkp-nfs-2-local*.service" - rsync_extra_args = ( - f"--filter=protect /{STATE_SUBDIR}/backup", - f"--exclude=/{STATE_SUBDIR}/backup", - ) + + @property + def rsync_extra_args(self) -> tuple[str, ...]: + return ( + f"--filter=protect /{STATE_SUBDIR}/backup", + f"--exclude=/{STATE_SUBDIR}/backup", + *( + f"--filter=protect /{STATE_SUBDIR}/{name}" + for name in self.volumes_missing_from_snapshot() + ), + ) + + def volumes_missing_from_snapshot(self) -> list[str]: + """Volume directories of the live export the snapshot does not hold. + + Returns: + The sorted names under the state path; empty when either side is + remote and cannot be listed. + """ + if is_remote(str(self.source_dir)) or is_remote(str(self.target_dir)): + return [] + live = self.target_dir / STATE_SUBDIR + if not live.is_dir(): + return [] + kept = self.source_dir / STATE_SUBDIR + return sorted( + entry.name + for entry in live.iterdir() + if entry.is_dir() and not (kept / entry.name).exists() + ) def main() -> int: diff --git a/roles/svc-bkp-nfs-2-local/files/shell/script.sh b/roles/svc-bkp-nfs-2-local/files/shell/script.sh index 415a7e893e..e2fec0621d 100644 --- a/roles/svc-bkp-nfs-2-local/files/shell/script.sh +++ b/roles/svc-bkp-nfs-2-local/files/shell/script.sh @@ -6,19 +6,18 @@ # $1 SOURCE_DIR NFS export base to back up # $2 BACKUPS_DIR local backup root # $3 REPO_NAME repository directory name inside the machine hash dir -# $4 EXCLUDE_REL optional path relative to SOURCE_DIR to exclude -# (the shared backup root inside the export; without it -# every generation snapshots all previous backups) +# $4.. EXCLUDE_REL optional paths relative to SOURCE_DIR to exclude # # Environment: # BKP_NFS_2_LOCAL_GENERATION optional generation name override # (defaults to the current timestamp) set -euo pipefail -SOURCE_DIR="${1:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL]}" -BACKUPS_DIR="${2:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL]}" -REPO_NAME="${3:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL]}" -EXCLUDE_REL="${4:-}" +SOURCE_DIR="${1:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL...]}" +BACKUPS_DIR="${2:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL...]}" +REPO_NAME="${3:?usage: script.sh SOURCE_DIR BACKUPS_DIR REPO_NAME [EXCLUDE_REL...]}" +shift 3 +EXCLUDE_RELS=("$@") if [[ ! -d "${SOURCE_DIR}" ]]; then echo "ERROR: ${SOURCE_DIR} missing; this host is expected to serve the NFS export" >&2 @@ -46,9 +45,11 @@ trap cleanup_failed_generation ERR mkdir -p "${DEST_DIR}" rsync_args=(-aHAX --numeric-ids --delete) -if [[ -n "${EXCLUDE_REL}" ]]; then - rsync_args+=(--exclude "/${EXCLUDE_REL#/}") -fi +for exclude_rel in "${EXCLUDE_RELS[@]}"; do + if [[ -n "${exclude_rel}" ]]; then + rsync_args+=(--exclude "/${exclude_rel#/}") + fi +done if [[ -n "${PREVIOUS_FILES}" ]]; then rsync_args+=(--link-dest "${PREVIOUS_FILES}") fi diff --git a/roles/svc-bkp-nfs-2-local/lookup_plugins/backup_nfs_excludes.py b/roles/svc-bkp-nfs-2-local/lookup_plugins/backup_nfs_excludes.py new file mode 100644 index 0000000000..30df9f96fe --- /dev/null +++ b/roles/svc-bkp-nfs-2-local/lookup_plugins/backup_nfs_excludes.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import contextlib +from collections.abc import Mapping +from typing import Any + +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from utils.storage.nfs import STATE_SUBDIR, backup_disabled_dirs + + +class LookupModule(LookupBase): + """Export-relative directories of the NFS-backed volumes declared + ``backup: false``, for the export rsync's ``--exclude`` list. + + Args: + terms: none. + variables: the play variables; ``DEPLOYMENT_MODE`` and ``storage`` + decide which volumes the swarm NFS rewrite puts on the export, and + the live merged ``applications`` config is read through them. + + Returns: + One list: the sorted ``/`` paths. + """ + + def run( + self, + terms: list[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[list[str]]: + 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] + + deployment_mode = vars_.get("DEPLOYMENT_MODE", "") + storage = vars_.get("storage") + if templar is not None: + with contextlib.suppress(Exception): + deployment_mode = templar.template(deployment_mode) + with contextlib.suppress(Exception): + storage = templar.template(storage) + storage_backend = "" + if isinstance(storage, Mapping): + storage_backend = str(storage.get("backend") or "") + + if not isinstance(applications, Mapping): + return [[]] + dirs = backup_disabled_dirs( + applications, + deployment_mode=str(deployment_mode or ""), + storage_backend=storage_backend, + ) + return [[f"{STATE_SUBDIR}/{name}" for name in dirs]] diff --git a/roles/svc-bkp-nfs-2-local/templates/systemctl.service.j2 b/roles/svc-bkp-nfs-2-local/templates/systemctl.service.j2 index 51b5f798e2..6aeb9395a5 100644 --- a/roles/svc-bkp-nfs-2-local/templates/systemctl.service.j2 +++ b/roles/svc-bkp-nfs-2-local/templates/systemctl.service.j2 @@ -5,4 +5,4 @@ OnFailure={{ SYS_SERVICE_ON_FAILURE_COMPOSE }} {{ SYS_SERVICE_CLEANUP_BACKUPS_FA [Service] Type=oneshot ExecStartPre={{ BIN_LOCK }} {{ SYS_SERVICE_GROUP_MANIPULATION | join(' ') }} --ignore {{ SYS_SERVICE_BACKUP_NFS_2_LOCAL }} --timeout "{{ SYS_TIMEOUT_BACKUP_SERVICES }}" -ExecStart={{ system_service_script_exec }} {{ BKP_NFS_2_LOCAL_SOURCE }} {{ DIR_BACKUPS }} {{ BKP_NFS_2_LOCAL_REPO_NAME }} {{ BKP_NFS_2_LOCAL_EXCLUDE }} +ExecStart={{ system_service_script_exec }} {{ BKP_NFS_2_LOCAL_SOURCE }} {{ DIR_BACKUPS }} {{ BKP_NFS_2_LOCAL_REPO_NAME }} {{ BKP_NFS_2_LOCAL_EXCLUDES | join(' ') }} diff --git a/roles/svc-bkp-nfs-2-local/vars/main.yml b/roles/svc-bkp-nfs-2-local/vars/main.yml index 92dbf970e9..59acb276a6 100644 --- a/roles/svc-bkp-nfs-2-local/vars/main.yml +++ b/roles/svc-bkp-nfs-2-local/vars/main.yml @@ -4,4 +4,4 @@ system_service_id: "{{ application_id }}" BKP_NFS_2_LOCAL_SOURCE: "{{ lookup('nfs_export_base') }}" BKP_NFS_2_LOCAL_REPO_NAME: backup-nfs-to-local -BKP_NFS_2_LOCAL_EXCLUDE: "{{ [lookup('nfs_state_path') | basename, DIR_BACKUPS | basename] | path_join }}" +BKP_NFS_2_LOCAL_EXCLUDES: "{{ [[lookup('nfs_state_path') | basename, DIR_BACKUPS | basename] | path_join] + lookup('backup_nfs_excludes') }}" diff --git a/scripts/tests/deploy/swarm/routine/backup/README.md b/scripts/tests/deploy/swarm/routine/backup/README.md index e4b278bdab..1872097da6 100644 --- a/scripts/tests/deploy/swarm/routine/backup/README.md +++ b/scripts/tests/deploy/swarm/routine/backup/README.md @@ -103,9 +103,9 @@ flowchart TB ## Applicability -- The drill runs whenever the app declares an NFS-flagged volume - (`PRIMARY_NFS_VOLUME`); apps without one skip it, since there is nothing - to prove a restore against. +- The drill runs whenever the app declares an NFS-flagged volume that is + not `backup: false` (`BACKUP_NFS_VOLUME`); apps without one skip it, + since there is nothing to prove a restore against. - The manager legs of steps 2, 4 and 9 run only when the app's include closure contains `svc-bkp-volume-2-local` / `svc-bkp-secrets-2-local`. `utils/tests/swarm/backup_repos.py` derives both the manager's inventory diff --git a/scripts/tests/deploy/swarm/routine/backup/base.sh b/scripts/tests/deploy/swarm/routine/backup/base.sh index 11788f6f3d..1a4cd442f7 100755 --- a/scripts/tests/deploy/swarm/routine/backup/base.sh +++ b/scripts/tests/deploy/swarm/routine/backup/base.sh @@ -50,7 +50,7 @@ DEV_TARGET="$(python3 -c "import sys, yaml; print(yaml.safe_load(open(sys.argv[1 DEV_DEST="${DEV_MOUNT}${DEV_TARGET}" if [ "${DRILL_PROBE:-false}" = "true" ]; then - if [ -n "${PRIMARY_NFS_VOLUME}" ]; then + if [ -n "${BACKUP_NFS_VOLUME}" ]; then echo "DRILL=yes" else echo "DRILL=no" @@ -58,8 +58,8 @@ if [ "${DRILL_PROBE:-false}" = "true" ]; then exit 0 fi -if [ -z "${PRIMARY_NFS_VOLUME}" ]; then - echo "SKIP drill: ${APP_ID} declares no NFS-flagged volume — nothing to prove a restore against" +if [ -z "${BACKUP_NFS_VOLUME}" ]; then + echo "SKIP drill: ${APP_ID} declares no backed-up NFS-flagged volume — nothing to prove a restore against" exit 0 fi if ! docker inspect "${BACKUP_NODE}" >/dev/null 2>&1; then @@ -71,10 +71,10 @@ if [ ! -f "${BACKUP_KEY_PATH}" ]; then exit 1 fi -NFS_VOL_DIR="${NFS_STATE_PATH}/${PRIMARY_NFS_VOLUME}" +NFS_VOL_DIR="${NFS_STATE_PATH}/${BACKUP_NFS_VOLUME}" MGR_MID="$(docker exec "${MGR}" sha256sum /etc/machine-id | cut -c1-64)" NFS_MID="$(docker exec "${NFS_SERVER}" sha256sum /etc/machine-id | cut -c1-64)" -echo "==> DR drill for ${APP_ID} (volume '${PRIMARY_NFS_VOLUME}')" +echo "==> DR drill for ${APP_ID} (volume '${BACKUP_NFS_VOLUME}')" TRIGGER_UNITS="${NODE_SRC}/scripts/tests/deploy/swarm/utils/trigger_units.sh" UNIT_DUMPS="${INFINITO_RESCUE_DIAGNOSTICS_DIR:?INFINITO_RESCUE_DIAGNOSTICS_DIR is not set - source scripts/meta/env/load.sh first}" @@ -117,11 +117,11 @@ fi echo "==> [3/9] locate the backup generation holding the marker" SRC_HOST="" -MARKER_PATH="$(docker exec "${NFS_SERVER}" find "${DIR_BACKUPS}/${NFS_MID}/${NFS_REPO}" -type f -name "${DR_MARKER}" -path "*/files/*/${PRIMARY_NFS_VOLUME}/${DR_MARKER}" 2>/dev/null | sort | tail -1 || true)" +MARKER_PATH="$(docker exec "${NFS_SERVER}" find "${DIR_BACKUPS}/${NFS_MID}/${NFS_REPO}" -type f -name "${DR_MARKER}" -path "*/files/*/${BACKUP_NFS_VOLUME}/${DR_MARKER}" 2>/dev/null | sort | tail -1 || true)" if [ -n "${MARKER_PATH}" ]; then SRC_HOST="${NFS_SERVER}" else - MARKER_PATH="$(docker exec "${MGR}" find "${DIR_BACKUPS}/${MGR_MID}/${VOLUME_REPO}" -type f -name "${DR_MARKER}" -path "*/${PRIMARY_NFS_VOLUME}/files/${DR_MARKER}" 2>/dev/null | sort | tail -1 || true)" + MARKER_PATH="$(docker exec "${MGR}" find "${DIR_BACKUPS}/${MGR_MID}/${VOLUME_REPO}" -type f -name "${DR_MARKER}" -path "*/${BACKUP_NFS_VOLUME}/files/${DR_MARKER}" 2>/dev/null | sort | tail -1 || true)" if [ -n "${MARKER_PATH}" ]; then SRC_HOST="${MGR}" fi @@ -137,7 +137,7 @@ SRC_REL="$(dirname "${MARKER_REL}")" echo " marker captured on ${SRC_HOST} at ${MARKER_REL}" VOL_MARKER_REL="" if [ "${SRC_HOST}" != "${MGR}" ]; then - _vol_marker="$(docker exec "${MGR}" find "${DIR_BACKUPS}/${MGR_MID}/${VOLUME_REPO}" -type f -name "${DR_MARKER}" -path "*/${PRIMARY_NFS_VOLUME}/files/${DR_MARKER}" 2>/dev/null | sort | tail -1 || true)" + _vol_marker="$(docker exec "${MGR}" find "${DIR_BACKUPS}/${MGR_MID}/${VOLUME_REPO}" -type f -name "${DR_MARKER}" -path "*/${BACKUP_NFS_VOLUME}/files/${DR_MARKER}" 2>/dev/null | sort | tail -1 || true)" [ -n "${_vol_marker}" ] && VOL_MARKER_REL="${_vol_marker#"${DIR_BACKUPS}"/}" fi @@ -285,7 +285,7 @@ for _node in "${MGR}" "${WRK1}" "${WRK2}"; do done for _i in $(seq 1 24); do if docker exec "${MGR}" sh -c \ - "touch '${DIR_VAR_LIB}/${PRIMARY_NFS_VOLUME}/.dr-coherence-probe' && rm -f '${DIR_VAR_LIB}/${PRIMARY_NFS_VOLUME}/.dr-coherence-probe'" 2>/dev/null; then + "touch '${DIR_VAR_LIB}/${BACKUP_NFS_VOLUME}/.dr-coherence-probe' && rm -f '${DIR_VAR_LIB}/${BACKUP_NFS_VOLUME}/.dr-coherence-probe'" 2>/dev/null; then break fi if [ "${_i}" -eq 24 ]; then @@ -317,12 +317,12 @@ if [ -n "${VOL_MARKER_REL}" ]; then docker exec "${MGR}" rm -rf "${DR_VOL_STAGE:?}" fi else - VOL_PATH="$(docker exec "${MGR}" docker volume inspect --format '{{if .Options}}{{.Options.device}}{{end}}' "${PRIMARY_NFS_VOLUME}" 2>/dev/null || true)" + VOL_PATH="$(docker exec "${MGR}" docker volume inspect --format '{{if .Options}}{{.Options.device}}{{end}}' "${BACKUP_NFS_VOLUME}" 2>/dev/null || true)" if [ -z "${VOL_PATH}" ]; then - echo " volume recover skipped: ${MGR} exposes no backing store for '${PRIMARY_NFS_VOLUME}' - the volume object is node-local and lives wherever the task ran, and the [8/9] export restore already carried its data" + echo " volume recover skipped: ${MGR} exposes no backing store for '${BACKUP_NFS_VOLUME}' - the volume object is node-local and lives wherever the task ran, and the [8/9] export restore already carried its data" else echo " volume repo holds no marker by design; proving the volume recover CLI against the bound backing store instead" - VOL_MOUNTPOINT="$(docker exec "${MGR}" docker volume inspect --format '{{.Mountpoint}}' "${PRIMARY_NFS_VOLUME}")" + VOL_MOUNTPOINT="$(docker exec "${MGR}" docker volume inspect --format '{{.Mountpoint}}' "${BACKUP_NFS_VOLUME}")" docker exec "${NFS_SERVER}" rm -f "${NFS_VOL_DIR}/${DR_MARKER}" DR_VOL_STAGE="/var/tmp/dr-volume-restore" docker exec "${MGR}" bash -c "rm -rf '${DR_VOL_STAGE}'; mkdir -p '${DR_VOL_STAGE}'" @@ -337,14 +337,14 @@ else echo "WARNING: the bind mount at ${VOL_MOUNTPOINT} refused the umount and stays behind" docker exec "${MGR}" rm -rf "${DR_VOL_STAGE:?}" if [ "${_vol_rc}" -ne 0 ]; then - echo "FAILURE: the volume recover CLI exited ${_vol_rc} restoring '${PRIMARY_NFS_VOLUME}' through its bound backing store" + echo "FAILURE: the volume recover CLI exited ${_vol_rc} restoring '${BACKUP_NFS_VOLUME}' through its bound backing store" exit 1 fi if ! docker exec "${NFS_SERVER}" test -f "${NFS_VOL_DIR}/${DR_MARKER}"; then echo "FAILURE: the volume recover CLI reported success but the marker did not reach the export on ${NFS_SERVER} (expected ${NFS_VOL_DIR}/${DR_MARKER})" exit 1 fi - echo " volume '${PRIMARY_NFS_VOLUME}' recovered through its bound backing store; marker verified server-side" + echo " volume '${BACKUP_NFS_VOLUME}' recovered through its bound backing store; marker verified server-side" fi fi diff --git a/scripts/tests/deploy/swarm/utils/_context.sh b/scripts/tests/deploy/swarm/utils/_context.sh index 8d3deaaf86..b02e30eeef 100755 --- a/scripts/tests/deploy/swarm/utils/_context.sh +++ b/scripts/tests/deploy/swarm/utils/_context.sh @@ -67,8 +67,9 @@ print(port) ")" NFS_VOLUMES="" +BACKUP_NFS_VOLUMES="" if [ -f "${ROLE_DIR}/meta/volumes.yml" ]; then - NFS_VOLUMES="$(python3 -c " + _nfs_volumes_py=" import sys, yaml with open('${ROLE_DIR}/meta/volumes.yml') as f: data = yaml.safe_load(f) or {} @@ -83,17 +84,22 @@ for v in entries: continue if v.get('nfs') is False: continue + if sys.argv[1:] == ['backed-up'] and v.get('backup') is False: + continue docker_name = v.get('docker_name') or v.get('name') if isinstance(docker_name, str): print(docker_name) -")" +" + NFS_VOLUMES="$(python3 -c "${_nfs_volumes_py}")" + BACKUP_NFS_VOLUMES="$(python3 -c "${_nfs_volumes_py}" backed-up)" fi PRIMARY_NFS_VOLUME="${NFS_VOLUMES%%$'\n'*}" +BACKUP_NFS_VOLUME="${BACKUP_NFS_VOLUMES%%$'\n'*}" NFS_CHECK_MOUNTPOINT="/mnt/nfs-check" -export APP_ID ENTITY STACK_NAME SERVICE_NAME PRIMARY_SERVICE_KEY CUSTOM_IMAGE_REPO DB_DEP NFS_VOLUMES PRIMARY_NFS_VOLUME DEFAULT_PLACEMENT_MANAGER PROBE_PORT NFS_CHECK_MOUNTPOINT +export APP_ID ENTITY STACK_NAME SERVICE_NAME PRIMARY_SERVICE_KEY CUSTOM_IMAGE_REPO DB_DEP NFS_VOLUMES PRIMARY_NFS_VOLUME BACKUP_NFS_VOLUME DEFAULT_PLACEMENT_MANAGER PROBE_PORT NFS_CHECK_MOUNTPOINT # Exits the calling chaos step (09/10/11) with success when the role is # manager-pinned (no worker task exists to drain). @@ -172,6 +178,7 @@ if [ "${SWARM_NFS_PILOT_VERBOSE:-0}" = "1" ]; then echo " CUSTOM_IMAGE_REPO=${CUSTOM_IMAGE_REPO}" echo " DB_DEP=${DB_DEP}" echo " PRIMARY_NFS_VOLUME=${PRIMARY_NFS_VOLUME}" + echo " BACKUP_NFS_VOLUME=${BACKUP_NFS_VOLUME}" echo " DEFAULT_PLACEMENT_MANAGER=${DEFAULT_PLACEMENT_MANAGER}" echo " PROBE_PORT=${PROBE_PORT}" fi diff --git a/tests/unit/python/roles/svc-bkp-nfs-2-local/files/test_script.py b/tests/unit/python/roles/svc-bkp-nfs-2-local/files/test_script.py index acf56c75f5..91e2bf7156 100644 --- a/tests/unit/python/roles/svc-bkp-nfs-2-local/files/test_script.py +++ b/tests/unit/python/roles/svc-bkp-nfs-2-local/files/test_script.py @@ -6,6 +6,7 @@ into older generations.""" import os import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -15,6 +16,9 @@ from . import PROJECT_ROOT SCRIPT = ( PROJECT_ROOT / "roles" / "svc-bkp-nfs-2-local" / "files" / "shell" / "script.sh" ) +RECOVER = ( + PROJECT_ROOT / "roles" / "svc-bkp-nfs-2-local" / "files" / "python" / "recover.py" +) REPO_NAME = "backup-nfs-to-local" @@ -30,7 +34,7 @@ class NfsBackupScriptTests(unittest.TestCase): def tearDown(self): self.tmp.cleanup() - def _run(self, source=None, generation=None, exclude=None): + def _run(self, source=None, generation=None, excludes=()): env = os.environ.copy() if generation is not None: env["BKP_NFS_2_LOCAL_GENERATION"] = generation @@ -40,9 +44,8 @@ class NfsBackupScriptTests(unittest.TestCase): str(source or self.source), str(self.backups), REPO_NAME, + *excludes, ] - if exclude is not None: - cmd.append(exclude) return subprocess.run( cmd, capture_output=True, @@ -132,12 +135,12 @@ class NfsBackupScriptTests(unittest.TestCase): (decoy / "decoy.txt").write_text("decoy") (self.source / "infinito-state" / "state.txt").write_text("state") - exclude = "infinito-state/backup" + excludes = ["infinito-state/backup"] self.assertEqual( - self._run(generation="20240101000000", exclude=exclude).returncode, 0 + self._run(generation="20240101000000", excludes=excludes).returncode, 0 ) self.assertEqual( - self._run(generation="20240101000001", exclude=exclude).returncode, 0 + self._run(generation="20240101000001", excludes=excludes).returncode, 0 ) for generation in self._generations(): @@ -148,6 +151,73 @@ class NfsBackupScriptTests(unittest.TestCase): (files / "app" / "infinito-state" / "backup" / "decoy.txt").is_file() ) + def test_every_excluded_volume_dir_stays_out_of_the_snapshot(self): + state = self.source / "infinito-state" + for name in ("backup", "docs_sites", "nextcloud_whiteboard_tmp"): + (state / name).mkdir(parents=True) + (state / name / "blob").write_text(name) + (state / "nextcloud_data").mkdir() + (state / "nextcloud_data" / "user.txt").write_text("user") + + result = self._run( + excludes=[ + "infinito-state/backup", + "infinito-state/docs_sites", + "infinito-state/nextcloud_whiteboard_tmp", + ] + ) + self.assertEqual(result.returncode, 0, result.stderr) + + files = self._generations()[0] / "files" / "infinito-state" + self.assertEqual(sorted(p.name for p in files.iterdir()), ["nextcloud_data"]) + self.assertTrue((files / "nextcloud_data" / "user.txt").is_file()) + + def test_restoring_a_generation_spares_excluded_volumes_and_the_backup_root(self): + state = self.source / "infinito-state" + self.backups = state / "backup" + self.backups.mkdir(parents=True) + db_file = state / "app_data" / "db" + site_file = state / "docs_sites" / "site" + db_file.parent.mkdir() + site_file.parent.mkdir() + db_file.write_text("v1") + site_file.write_text("regenerable") + excludes = ["infinito-state/backup", "infinito-state/docs_sites"] + for generation in ("20240101000000", "20240101000001"): + result = self._run(generation=generation, excludes=excludes) + self.assertEqual(result.returncode, 0, result.stderr) + first, second = (g / "files" / "infinito-state" for g in self._generations()) + self.assertEqual(sorted(p.name for p in second.iterdir()), ["app_data"]) + self.assertEqual( + (first / "app_data" / "db").stat().st_ino, + (second / "app_data" / "db").stat().st_ino, + ) + + db_file.write_text("corrupted") + (state / "app_data" / "stale").write_text("stale") + site_file.write_text("rebuilt") + result = subprocess.run( + [ + sys.executable, + str(RECOVER), + str(second.parent), + str(self.source), + "--no-safety-backup", + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + db = db_file.read_text() # nocheck: cache-read tempdir fixture + site = site_file.read_text() # nocheck: cache-read tempdir fixture + self.assertEqual(db, "v1") + self.assertEqual(site, "rebuilt") + self.assertFalse((state / "app_data" / "stale").exists()) + self.assertTrue((first / "app_data" / "db").is_file()) + self.assertTrue((second / "app_data" / "db").is_file()) + def test_deleted_file_vanishes_only_from_new_generation(self): (self.source / "keep.txt").write_text("keep") (self.source / "gone.txt").write_text("gone") diff --git a/tests/unit/python/roles/svc-bkp-nfs-2-local/lookup_plugins/__init__.py b/tests/unit/python/roles/svc-bkp-nfs-2-local/lookup_plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/roles/svc-bkp-nfs-2-local/lookup_plugins/test_backup_nfs_excludes.py b/tests/unit/python/roles/svc-bkp-nfs-2-local/lookup_plugins/test_backup_nfs_excludes.py new file mode 100644 index 0000000000..a788d93f67 --- /dev/null +++ b/tests/unit/python/roles/svc-bkp-nfs-2-local/lookup_plugins/test_backup_nfs_excludes.py @@ -0,0 +1,138 @@ +import importlib.util +import unittest +import unittest.mock as mock +from pathlib import Path +from types import ModuleType +from typing import ClassVar + +PLUGIN = ( + Path(__file__).resolve().parents[6] + / "roles" + / "svc-bkp-nfs-2-local" + / "lookup_plugins" + / "backup_nfs_excludes.py" +) + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("backup_nfs_excludes", PLUGIN) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _run( + module: ModuleType, + applications: dict, + variables: dict, + templar: object | None = None, +) -> list[str]: + loader = mock.MagicMock() + loader.get.return_value.run.return_value = [applications] + with mock.patch.object(module, "lookup_loader", loader): + plugin = module.LookupModule() + if templar is not None: + plugin._templar = templar + return plugin.run(None, variables=variables)[0] + + +class TestBackupNfsExcludes(unittest.TestCase): + SWARM_NFS: ClassVar[dict] = { + "DEPLOYMENT_MODE": "swarm", + "storage": {"backend": "nfs"}, + } + + @classmethod + def setUpClass(cls) -> None: + cls._module = _load_module() + + def _excludes(self, applications, variables=None, templar=None): + return _run( + type(self)._module, + applications, + dict(self.SWARM_NFS) if variables is None else variables, + templar, + ) + + def test_backup_disabled_volume_dirs_are_excluded(self) -> None: + apps = { + "web-app-docs": { + "volumes": {"docs_sites": {"type": "volume", "backup": False}} + }, + "web-app-nextcloud": { + "volumes": { + "data": {"type": "volume", "name": "nextcloud_data"}, + "whiteboard_tmp": { + "type": "volume", + "name": "nextcloud_whiteboard_tmp", + "backup": False, + }, + } + }, + } + self.assertEqual( + self._excludes(apps), + [ + "infinito-state/docs_sites", + "infinito-state/nextcloud_whiteboard_tmp", + ], + ) + + def test_the_export_dir_is_the_semantic_key_not_the_docker_name(self) -> None: + apps = {"web-app-docs": {"volumes": {"docs_sites": {"backup": False}}}} + self.assertNotIn("infinito-state/docs_docs_sites", self._excludes(apps)) + + def test_compose_mode_excludes_nothing(self) -> None: + apps = {"web-app-docs": {"volumes": {"docs_sites": {"backup": False}}}} + variables = {"DEPLOYMENT_MODE": "compose", "storage": {"backend": "nfs"}} + self.assertEqual(self._excludes(apps, variables), []) + + def test_a_volume_off_the_export_is_not_excluded(self) -> None: + apps = { + "web-svc-xmpp": { + "volumes": { + "home": {"name": "ejabberd_home", "backup": False, "nfs": False} + } + }, + "web-svc-seaweedfs": { + "volumes": {"data": {"name": "seaweedfs_data", "backup": False}} + }, + } + self.assertEqual(self._excludes(apps), []) + + def test_a_dir_a_backed_up_volume_also_binds_stays_captured(self) -> None: + apps = { + "web-app-docs": {"volumes": {"shared": {"backup": False}}}, + "web-app-nextcloud": {"volumes": {"shared": {"type": "volume"}}}, + } + self.assertEqual(self._excludes(apps), []) + + def test_a_config_entry_is_never_excluded(self) -> None: + apps = { + "web-app-nextcloud": { + "volumes": {"loader": {"type": "config", "backup": False}} + } + } + self.assertEqual(self._excludes(apps), []) + + def test_the_deployment_mode_expression_is_templated(self) -> None: + expression = ( + "{{ 'swarm' if (groups['svc-swarm-node'] | default([]) | length) > 1" + " else 'compose' }}" + ) + templar = mock.Mock() + templar.template.side_effect = lambda value: ( + "swarm" if value == expression else value + ) + apps = {"web-app-docs": {"volumes": {"docs_sites": {"backup": False}}}} + self.assertEqual( + self._excludes( + apps, {**self.SWARM_NFS, "DEPLOYMENT_MODE": expression}, templar + ), + ["infinito-state/docs_sites"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/utils/recovery/test_role_recover_scripts.py b/tests/unit/python/utils/recovery/test_role_recover_scripts.py index a2756be5e6..c53d28952f 100644 --- a/tests/unit/python/utils/recovery/test_role_recover_scripts.py +++ b/tests/unit/python/utils/recovery/test_role_recover_scripts.py @@ -188,6 +188,27 @@ class NfsRecoverTests(unittest.TestCase): ) self.assertFalse((target / "stale.txt").exists()) + def test_a_volume_the_snapshot_does_not_hold_survives_the_restore(self): + mod = _load("svc-bkp-nfs-2-local", "nfs_recover") + state = mod.STATE_SUBDIR + with tempfile.TemporaryDirectory() as td: + source = Path(td) / "source" + target = Path(td) / "target" + (source / state / "app_data").mkdir(parents=True) + (source / state / "app_data" / "db").write_text("from-snapshot") + (target / state / "app_data").mkdir(parents=True) + (target / state / "app_data" / "stale").write_text("pre-recover") + (target / state / "docs_sites").mkdir(parents=True) + (target / state / "docs_sites" / "site").write_text("regenerable") + recovery = mod.NfsExportRecovery( + str(source), str(target), service_backup=False + ) + self.assertEqual(recovery.volumes_missing_from_snapshot(), ["docs_sites"]) + recovery.restore() + self.assertTrue((target / state / "docs_sites" / "site").exists()) + self.assertTrue((target / state / "app_data" / "db").exists()) + self.assertFalse((target / state / "app_data" / "stale").exists()) + def test_main_wires_argparse_to_recovery(self): mod = _load("svc-bkp-nfs-2-local", "nfs_recover") for argv, expect_backup in ( diff --git a/tests/unit/python/utils/storage/test_nfs.py b/tests/unit/python/utils/storage/test_nfs.py index 281cf6bf54..b0d0bdfba4 100644 --- a/tests/unit/python/utils/storage/test_nfs.py +++ b/tests/unit/python/utils/storage/test_nfs.py @@ -6,6 +6,7 @@ from utils.storage.nfs import ( mount_opts, state_path, swarm_nfs_backed, + volume_dir, ) @@ -62,6 +63,15 @@ class TestNfsHelpers(unittest.TestCase): "1.2.3.4:/srv/nfs/infinito-state", ) + def test_volume_dir_is_the_pinned_name(self): + self.assertEqual( + volume_dir("whiteboard_tmp", {"name": "nextcloud_whiteboard_tmp"}), + "nextcloud_whiteboard_tmp", + ) + + def test_volume_dir_falls_back_to_the_key_without_a_pin(self): + self.assertEqual(volume_dir("docs_sites", {"backup": False}), "docs_sites") + class TestSwarmNfsBacked(unittest.TestCase): """The predicate mirrors the compose_volumes rewrite decision: swarm mode diff --git a/utils/storage/nfs.py b/utils/storage/nfs.py index 9093f4fb3a..80a0683eca 100644 --- a/utils/storage/nfs.py +++ b/utils/storage/nfs.py @@ -125,3 +125,50 @@ def swarm_nfs_backed(entry, *, application_id, deployment_mode, storage_backend) if str(get_role_placement(application_id) or "").strip().lower() == "manager": return False return not _role_forces_other_mode(application_id) + + +def volume_dir(semantic_name, entry): + """Directory under the NFS state path the swarm rewrite binds a volume to. + + Args: + semantic_name: the volume's key in the compose ``volumes:`` block. + entry: the volume's spec; a pinned ``name`` wins over the key. + """ + pinned = entry.get("name") if isinstance(entry, Mapping) else None + return str(pinned) if pinned else str(semantic_name) + + +def backup_disabled_dirs(applications, *, deployment_mode, storage_backend): + """State-path directories of the NFS-backed volumes declared ``backup: false``. + + Args: + applications: merged applications mapping whose ``volumes`` carry the + canonical ``meta/volumes.yml`` entries. + deployment_mode: cluster-wide ``compose`` or ``swarm``. + storage_backend: the ``storage.backend`` value, e.g. ``local`` or ``nfs``. + + Returns: + The sorted directory names, without any a backed-up volume also binds to. + """ + disabled: set[str] = set() + kept: set[str] = set() + for app_id, app in applications.items(): + volumes = app.get("volumes") if isinstance(app, Mapping) else None + if not isinstance(volumes, Mapping): + continue + for semantic_name, entry in volumes.items(): + if ( + not isinstance(entry, Mapping) + or entry.get("type", "volume") != "volume" + ): + continue + if not swarm_nfs_backed( + entry, + application_id=str(app_id), + deployment_mode=deployment_mode, + storage_backend=storage_backend, + ): + continue + target = disabled if entry.get("backup") is False else kept + target.add(volume_dir(semantic_name, entry)) + return sorted(disabled - kept)