0.27.2_hotfix1

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 15:39:33 +02:00
1 parent 177297c529
commit bcc9f20345
2 files changed
+145 -56

No files matched your search

+92 -4
View File
@@ -3,12 +3,21 @@ from __future__ import annotations
from deploy_core import * # re-exported internal deployment primitives
ACME_RUNTIME_NAMES = frozenset({'acme-python', 'acme-python.installing', 'acme-python.virtualenv-bootstrap'})
CERTBOT_PACKAGES = ('certbot==5.8.0', 'certbot-dns-cloudflare==5.8.0', 'certbot-dns-ovh==5.8.0', 'certbot-dns-rfc2136==5.8.0')
def _under_acme_runtime(source: Path, path: Path) -> bool:
relative = path.relative_to(source)
return bool(relative.parts) and relative.parts[0] in ACME_RUNTIME_NAMES
def checked_tree(source: Path) -> None:
"""Accept only regular files/directories and relative Certbot-internal symlinks."""
"""Accept durable data; managed ACME Python environments are rebuildable."""
boundary = source.resolve(strict=True)
if source.is_symlink():
raise ValueError('Data root cannot be a symlink.')
for p in source.rglob('*'):
if _under_acme_runtime(source, p):
continue
st = p.lstat()
if stat.S_ISLNK(st.st_mode):
target = os.readlink(p)
@@ -17,12 +26,88 @@ def checked_tree(source: Path) -> None:
elif not (stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode)):
raise ValueError('Data backup contains a special file.')
def copy_data(source: Path, target: Path) -> None:
checked_tree(source)
shutil.copytree(source, target, symlinks=True)
root = source.resolve(strict=True)
def ignore(directory: str, names: list[str]) -> set[str]:
return set(names) & ACME_RUNTIME_NAMES if Path(directory).resolve(strict=True) == root else set()
shutil.copytree(source, target, symlinks=True, ignore=ignore)
os.chmod(target, 0o700)
def _run_user(account, data: Path, args: list[str], timeout: int) -> subprocess.CompletedProcess:
env = os.environ.copy(); env.update(HOME=str(data), PATH='/usr/local/bin:/usr/bin:/bin')
env.pop('PYTHONHOME', None); env.pop('PYTHONPATH', None)
return subprocess.run(['runuser', '-u', account.pw_name, '--', *args], check=False, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, env=env)
def _acme_python(data: Path) -> Path:
return data / 'acme-python' / 'bin/python'
def _acme_runtime_valid(data: Path, account) -> bool:
python = _acme_python(data)
if not python.is_file():
return False
checks = ([str(python), '-I', '-c', 'import certbot, certbot_dns_cloudflare, certbot_dns_ovh, certbot_dns_rfc2136'],
[str(python), '-I', '-m', 'certbot', '--version'])
try:
return all(_run_user(account, data, command, 30).returncode == 0 for command in checks)
except (OSError, subprocess.SubprocessError):
return False
def prepare_managed_acme_runtime(data: Path, account) -> None:
if _acme_runtime_valid(data, account):
return
for name in ACME_RUNTIME_NAMES:
path = data / name
if path.is_symlink() or (path.exists() and not path.is_dir()):
path.unlink(missing_ok=True)
elif path.is_dir():
shutil.rmtree(path, ignore_errors=True)
system_python = shutil.which('python3') or shutil.which('python')
if not system_python:
print('WARNING: managed ACME runtime rebuild skipped: Python is unavailable.', file=sys.stderr); return
target = data / 'acme-python'
try:
created = _run_user(account, data, [system_python, '-m', 'venv', str(target)], 180)
if created.returncode != 0:
bootstrap = data / 'acme-python.virtualenv-bootstrap'
pip_ok = _run_user(account, data, [system_python, '-m', 'pip', '--version'], 30)
if pip_ok.returncode != 0:
raise RuntimeError(created.stderr.strip()[-1200:])
install_ve = _run_user(account, data, [system_python, '-m', 'pip', 'install', '--disable-pip-version-check',
'--no-input', '--no-cache-dir', '--retries', '3', '--timeout', '30', '--target', str(bootstrap), 'virtualenv>=20,<21'], 600)
if install_ve.returncode != 0:
raise RuntimeError(install_ve.stderr.strip()[-1200:])
create_ve = _run_user(account, data, [system_python, '-c',
"import runpy,sys;sys.path.insert(0,sys.argv[1]);sys.argv=['virtualenv',sys.argv[2]];runpy.run_module('virtualenv',run_name='__main__')",
str(bootstrap), str(target)], 180)
shutil.rmtree(bootstrap, ignore_errors=True)
if create_ve.returncode != 0:
raise RuntimeError(create_ve.stderr.strip()[-1200:])
python = _acme_python(data)
if _run_user(account, data, [str(python), '-m', 'pip', '--version'], 30).returncode != 0:
ensured = _run_user(account, data, [str(python), '-m', 'ensurepip', '--upgrade'], 120)
if ensured.returncode != 0:
raise RuntimeError(ensured.stderr.strip()[-1200:])
installed = _run_user(account, data, [str(python), '-m', 'pip', 'install', '--disable-pip-version-check',
'--no-input', '--no-cache-dir', '--retries', '3', '--timeout', '30', *CERTBOT_PACKAGES], 600)
if installed.returncode != 0 or not _acme_runtime_valid(data, account):
raise RuntimeError(installed.stderr.strip()[-1200:])
print(f'Managed ACME runtime ready: {target}')
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
for name in ACME_RUNTIME_NAMES:
shutil.rmtree(data / name, ignore_errors=True)
print(f'WARNING: managed ACME runtime rebuild failed; system Certbot fallback remains available: {error}', file=sys.stderr)
def restore_managed_acme_runtime(failed: Path, restored: Path) -> None:
for name in ACME_RUNTIME_NAMES:
source, target = failed / name, restored / name
if source.is_dir() and not source.is_symlink() and not target.exists():
try:
source.rename(target)
except OSError:
shutil.copytree(source, target, symlinks=True)
def chown_tree(path: Path, uid: int, gid: int) -> None:
os.chown(path, uid, gid)
@@ -288,6 +373,7 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
if reset_config:
reset_saved_config(data)
apply_geoip_update(data, geoip_stage, geoip_names, account.pw_uid, account.pw_gid)
prepare_managed_acme_runtime(data, account)
switch(prefix, release)
write_private(UNIT_PATH, service_unit(replacement).encode(), 0o644)
run(['systemctl', 'daemon-reload'])
@@ -303,7 +389,9 @@ def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: boo
stop_for_managed_update(data)
failed = data.with_name(data.name + '.failed-' + uuid.uuid4().hex[:12])
data.rename(failed) # keep the failed generation for diagnosis; do not delete it
copy_data(backup / 'data', data); chown_tree(data, account.pw_uid, account.pw_gid)
copy_data(backup / 'data', data)
restore_managed_acme_runtime(failed, data)
chown_tree(data, account.pw_uid, account.pw_gid)
switch(prefix, record['release'])
restore_service_unit(backup)
write_private(prefix / 'install.json', json.dumps(record, indent=2).encode())