Files
modulus/modulus/filesystem.py
T
khodowany 3efbe5020a
publish / publish (push) Successful in 10m29s
Package
2026-09-30 19:45:17 -05:00

57 lines
2.2 KiB
Python

"""Publish validated build directories with rollback on a failed replacement."""
import contextlib
import pathlib
import shutil
import tempfile
def has_symlink(path):
path = pathlib.Path(path).absolute()
return any(parent.is_symlink() for parent in (path, *path.parents))
@contextlib.contextmanager
def publish_directories(replacements):
pairs = [(pathlib.Path(source).absolute(), pathlib.Path(target).absolute()) for source, target in replacements]
paths = [path for pair in pairs for path in pair]
for path in paths:
if has_symlink(path):
raise ValueError(f'publication paths must not contain symlinks: {path}')
pairs = [(source.resolve(), target.resolve()) for source, target in pairs]
paths = [path for pair in pairs for path in pair]
for index, path in enumerate(paths):
if any(path == other or path.is_relative_to(other) or other.is_relative_to(path) for other in paths[:index]):
raise ValueError('publication paths must not overlap')
for source, target in pairs:
if not source.is_dir() or (target.exists() and not target.is_dir()):
raise ValueError('publication requires directories')
target.parent.mkdir(parents=True, exist_ok=True)
if source.stat().st_dev != target.parent.stat().st_dev:
raise ValueError('publication must remain on the same filesystem')
installed, backups, recovery = [], [], []
try:
for source, target in pairs:
temporary = pathlib.Path(tempfile.mkdtemp(prefix='.publish_', dir=target.parent))
recovery.append(temporary)
if target.exists():
backup = temporary / 'previous'
target.rename(backup)
backups.append((backup, target))
source.rename(target)
installed.append(target)
yield
except BaseException:
try:
for target in reversed(installed):
if target.exists():
shutil.rmtree(target)
for backup, target in reversed(backups):
backup.rename(target)
except BaseException as error:
raise RuntimeError(f'publication rollback failed; recovery directories retained: {recovery}') from error
for temporary in recovery:
shutil.rmtree(temporary)
raise
else:
for temporary in recovery:
shutil.rmtree(temporary)