The remote had one commit this branch did not (03f8a85), which adds the
Apache-2.0 LICENSE, the NOTICE and SPDX headers to every .py and .sh
file. The merge was clean - the headers sit at the top of each file and
my changes are further down.
The new files this branch added (languages.py, translations.py, the
cluster package, the new tests and scripts) were written after the
merge base, so the licence commit never saw them. They now carry the
same header, so the convention holds across the whole tree.
Nothing else changed. 883 tests passed / 8 failed - the same 8
pre-existing failures.
144 lines
4.6 KiB
Python
144 lines
4.6 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""NFS mount management for UmbraAudio cluster integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from .config import ClusterConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NFSMounts:
|
|
"""Manages NFS mounts for cluster shared storage."""
|
|
|
|
def __init__(self, config: ClusterConfig) -> None:
|
|
self.config = config
|
|
|
|
def is_mounted(self, mount_point: Path) -> bool:
|
|
"""Check if a path is mounted."""
|
|
try:
|
|
result = subprocess.run(
|
|
["findmnt", "-n", "-o", "TARGET", str(mount_point)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0 and result.stdout.strip() == str(mount_point)
|
|
except Exception:
|
|
return False
|
|
|
|
def mount(self, export: str, mount_point: Path, options: str | None = None) -> bool:
|
|
"""Mount an NFS export."""
|
|
mount_point.mkdir(parents=True, exist_ok=True)
|
|
|
|
if self.is_mounted(mount_point):
|
|
logger.debug("Already mounted: %s", mount_point)
|
|
return True
|
|
|
|
opts = options or "rw,hard,intr,rsize=1048576,wsize=1048576,timeo=600,retrans=2,vers=4.2"
|
|
cmd = ["mount", "-t", "nfs4", "-o", opts, export, str(mount_point)]
|
|
|
|
try:
|
|
logger.info("Mounting %s → %s", export, mount_point)
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if result.returncode == 0:
|
|
logger.info("Mounted: %s", mount_point)
|
|
return True
|
|
else:
|
|
logger.error("Mount failed: %s", result.stderr)
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("Mount timed out: %s", mount_point)
|
|
return False
|
|
except Exception as e:
|
|
logger.error("Mount error: %s", e)
|
|
return False
|
|
|
|
def unmount(self, mount_point: Path) -> bool:
|
|
"""Unmount an NFS mount."""
|
|
if not self.is_mounted(mount_point):
|
|
return True
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["umount", str(mount_point)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
logger.info("Unmounted: %s", mount_point)
|
|
return True
|
|
else:
|
|
logger.error("Unmount failed: %s", result.stderr)
|
|
return False
|
|
except Exception as e:
|
|
logger.error("Unmount error: %s", e)
|
|
return False
|
|
|
|
def ensure_all(self) -> dict[str, bool]:
|
|
"""Ensure all configured NFS mounts are present."""
|
|
results = {}
|
|
for name, (export, mount_point) in self.config.nfs_mounts.items():
|
|
results[name] = self.mount(export, mount_point)
|
|
return results
|
|
|
|
|
|
def ensure_nfs_mounts(config: ClusterConfig) -> dict[str, bool]:
|
|
"""Ensure all NFS mounts are present (convenience function)."""
|
|
mounts = NFSMounts(config)
|
|
return mounts.ensure_all()
|
|
|
|
|
|
def check_nfs_server(config: ClusterConfig) -> bool:
|
|
"""Check if NFS server is reachable and exporting expected paths."""
|
|
try:
|
|
# Check rpcbind
|
|
result = subprocess.run(
|
|
["rpcinfo", "-p", config.nfs_server],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.warning("rpcinfo failed: %s", result.stderr)
|
|
return False
|
|
|
|
# Check for NFS exports
|
|
result = subprocess.run(
|
|
["showmount", "-e", config.nfs_server],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.warning("showmount failed: %s", result.stderr)
|
|
return False
|
|
|
|
exports = result.stdout
|
|
required = [
|
|
config.nfs_productions_path,
|
|
config.nfs_artifacts_path,
|
|
config.nfs_covers_path,
|
|
config.nfs_audio_path,
|
|
]
|
|
missing = [p for p in required if p not in exports]
|
|
if missing:
|
|
logger.warning("Missing NFS exports: %s", missing)
|
|
return False
|
|
|
|
logger.info("NFS server %s exports verified", config.nfs_server)
|
|
return True
|
|
except Exception as e:
|
|
logger.error("NFS server check failed: %s", e)
|
|
return False
|