57 lines
2.2 KiB
Python
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)
|